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:
@@ -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", () => {
|
describe("POST /admin/inventory-items/:id", () => {
|
||||||
|
|||||||
@@ -88,16 +88,25 @@ medusaIntegrationTestRunner({
|
|||||||
|
|
||||||
it("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 = {
|
const payload = {
|
||||||
quantity: 100000,
|
quantity: 10000,
|
||||||
inventory_item_id: inventoryItem1.id,
|
inventory_item_id: inventoryItem1.id,
|
||||||
line_item_id: "line-item-id",
|
line_item_id: "line-item-id",
|
||||||
location_id: stockLocation1.id,
|
location_id: stockLocation1.id,
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await api
|
const res = await api
|
||||||
.post(`/admin/reservations`, payload, adminHeaders)
|
.post(
|
||||||
|
`/admin/reservations?fields=*inventory_item.location_levels`,
|
||||||
|
payload,
|
||||||
|
adminHeaders
|
||||||
|
)
|
||||||
.catch((err) => err)
|
.catch((err) => err)
|
||||||
|
|
||||||
|
const res1 = await api.get(
|
||||||
|
`/admin/inventory-items/${inventoryItem1.id}`,
|
||||||
|
adminHeaders
|
||||||
|
)
|
||||||
|
|
||||||
expect(res.response.status).toBe(400)
|
expect(res.response.status).toBe(400)
|
||||||
expect(res.response.data).toEqual({
|
expect(res.response.data).toEqual({
|
||||||
type: "not_allowed",
|
type: "not_allowed",
|
||||||
@@ -133,8 +142,7 @@ medusaIntegrationTestRunner({
|
|||||||
reservationId = reservationResponse.data.reservation.id
|
reservationId = reservationResponse.data.reservation.id
|
||||||
})
|
})
|
||||||
|
|
||||||
// TODO: Implement this on the module
|
it("should throw if available quantity is less than reservation quantity", async () => {
|
||||||
it.skip("should throw if available quantity is less than reservation quantity", async () => {
|
|
||||||
const payload = { quantity: 100000 }
|
const payload = { quantity: 100000 }
|
||||||
const res = await api
|
const res = await api
|
||||||
.post(`/admin/reservations/${reservationId}`, payload, adminHeaders)
|
.post(`/admin/reservations/${reservationId}`, payload, adminHeaders)
|
||||||
|
|||||||
@@ -351,18 +351,33 @@ medusaIntegrationTestRunner({
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
const inventoryItem = await inventoryModule.create({
|
const inventoryItem = (
|
||||||
sku: "inv-1234",
|
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,
|
inventory_item_id: inventoryItem.id,
|
||||||
location_id: location.id,
|
location_id: location.id,
|
||||||
stocked_quantity: 2,
|
description: "test description",
|
||||||
reserved_quantity: 2,
|
quantity: 2,
|
||||||
},
|
},
|
||||||
])
|
adminHeaders
|
||||||
|
)
|
||||||
|
|
||||||
const region = await regionModuleService.create({
|
const region = await regionModuleService.create({
|
||||||
name: "US",
|
name: "US",
|
||||||
|
|||||||
@@ -3,23 +3,83 @@ import {
|
|||||||
createWorkflow,
|
createWorkflow,
|
||||||
transform,
|
transform,
|
||||||
} from "@medusajs/workflows-sdk"
|
} from "@medusajs/workflows-sdk"
|
||||||
import {
|
import { createInventoryItemsStep } from "../steps"
|
||||||
attachInventoryItemToVariants,
|
|
||||||
createInventoryItemsStep,
|
|
||||||
validateInventoryItemsForCreate,
|
|
||||||
} from "../steps"
|
|
||||||
|
|
||||||
import { InventoryNext } from "@medusajs/types"
|
import { InventoryNext } from "@medusajs/types"
|
||||||
|
import { createInventoryLevelsWorkflow } from "./create-inventory-levels"
|
||||||
|
|
||||||
|
type LocationLevelWithoutInventory = Omit<
|
||||||
|
InventoryNext.CreateInventoryLevelInput,
|
||||||
|
"inventory_item_id"
|
||||||
|
>
|
||||||
interface WorkflowInput {
|
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 createInventoryItemsWorkflowId = "create-inventory-items-workflow"
|
||||||
export const createInventoryItemsWorkflow = createWorkflow(
|
export const createInventoryItemsWorkflow = createWorkflow(
|
||||||
createInventoryItemsWorkflowId,
|
createInventoryItemsWorkflowId,
|
||||||
(input: WorkflowData<WorkflowInput>) => {
|
(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
|
return items
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ export type ReservationItemDTO = {
|
|||||||
line_item_id?: string | null
|
line_item_id?: string | null
|
||||||
description?: string | null
|
description?: string | null
|
||||||
created_by?: string | null
|
created_by?: string | null
|
||||||
|
allow_backorder?: boolean
|
||||||
metadata: Record<string, unknown> | null
|
metadata: Record<string, unknown> | null
|
||||||
created_at: string | Date
|
created_at: string | Date
|
||||||
updated_at: string | Date
|
updated_at: string | Date
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ export interface ReservationItemDTO {
|
|||||||
*/
|
*/
|
||||||
description?: string | null
|
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.
|
* The created by of the reservation item.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -11,10 +11,6 @@ export interface CreateInventoryLevelInput {
|
|||||||
* The stocked quantity of the associated inventory item in the associated location.
|
* The stocked quantity of the associated inventory item in the associated location.
|
||||||
*/
|
*/
|
||||||
stocked_quantity?: number
|
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.
|
* The incoming quantity of the associated inventory item in the associated location.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export interface UpdateReservationItemInput {
|
|||||||
* The description of the reservation item.
|
* The description of the reservation item.
|
||||||
*/
|
*/
|
||||||
description?: string
|
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.
|
* Holds custom data in key-value pairs.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ export const AdminCreateInventoryItem = z
|
|||||||
requires_shipping: z.boolean().optional(),
|
requires_shipping: z.boolean().optional(),
|
||||||
thumbnail: z.string().optional(),
|
thumbnail: z.string().optional(),
|
||||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
location_levels: z.array(AdminCreateInventoryLocationLevel).optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
|
||||||
|
|||||||
@@ -209,6 +209,25 @@ moduleIntegrationTestRunner({
|
|||||||
expect.objectContaining({ id: expect.any(String), ...data[1] }),
|
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", () => {
|
describe("update", () => {
|
||||||
@@ -301,8 +320,10 @@ moduleIntegrationTestRunner({
|
|||||||
|
|
||||||
describe("updateReservationItems", () => {
|
describe("updateReservationItems", () => {
|
||||||
let reservationItem
|
let reservationItem
|
||||||
|
let inventoryItem
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const inventoryItem = await service.create({
|
inventoryItem = await service.create({
|
||||||
sku: "test-sku",
|
sku: "test-sku",
|
||||||
origin_country: "test-country",
|
origin_country: "test-country",
|
||||||
})
|
})
|
||||||
@@ -311,26 +332,26 @@ moduleIntegrationTestRunner({
|
|||||||
{
|
{
|
||||||
inventory_item_id: inventoryItem.id,
|
inventory_item_id: inventoryItem.id,
|
||||||
location_id: "location-1",
|
location_id: "location-1",
|
||||||
stocked_quantity: 2,
|
stocked_quantity: 10,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
inventory_item_id: inventoryItem.id,
|
inventory_item_id: inventoryItem.id,
|
||||||
location_id: "location-2",
|
location_id: "location-2",
|
||||||
stocked_quantity: 2,
|
stocked_quantity: 10,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
reservationItem = await service.createReservationItems({
|
reservationItem = await service.createReservationItems({
|
||||||
inventory_item_id: inventoryItem.id,
|
inventory_item_id: inventoryItem.id,
|
||||||
location_id: "location-1",
|
location_id: "location-1",
|
||||||
quantity: 2,
|
quantity: 3,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should update a reservationItem", async () => {
|
it("should update a reservationItem", async () => {
|
||||||
const update = {
|
const update = {
|
||||||
id: reservationItem.id,
|
id: reservationItem.id,
|
||||||
quantity: 5,
|
quantity: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await service.updateReservationItems(update)
|
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 () => {
|
it("should adjust reserved_quantity of inventory level after updates increasing reserved quantity", async () => {
|
||||||
const update = {
|
const update = {
|
||||||
id: reservationItem.id,
|
id: reservationItem.id,
|
||||||
quantity: 5,
|
quantity: 7,
|
||||||
}
|
}
|
||||||
|
|
||||||
await service.updateReservationItems(update)
|
await service.updateReservationItems(update)
|
||||||
@@ -358,7 +379,7 @@ moduleIntegrationTestRunner({
|
|||||||
it("should adjust reserved_quantity of inventory level after updates decreasing reserved quantity", async () => {
|
it("should adjust reserved_quantity of inventory level after updates decreasing reserved quantity", async () => {
|
||||||
const update = {
|
const update = {
|
||||||
id: reservationItem.id,
|
id: reservationItem.id,
|
||||||
quantity: 1,
|
quantity: 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
await service.updateReservationItems(update)
|
await service.updateReservationItems(update)
|
||||||
@@ -371,10 +392,26 @@ moduleIntegrationTestRunner({
|
|||||||
|
|
||||||
expect(inventoryLevel.reserved_quantity).toEqual(update.quantity)
|
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", () => {
|
describe("deleteReservationItemsByLineItem", () => {
|
||||||
let inventoryItem: InventoryItemDTO
|
let inventoryItem: InventoryItemDTO
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
inventoryItem = await service.create({
|
inventoryItem = await service.create({
|
||||||
sku: "test-sku",
|
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({
|
const reservationsPreDeleted = await service.listReservationItems({
|
||||||
line_item_id: "line-item-id",
|
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")
|
await service.deleteReservationItemsByLineItem("line-item-id")
|
||||||
|
|
||||||
const inventoryLevel =
|
const inventoryLevel =
|
||||||
@@ -543,7 +590,7 @@ moduleIntegrationTestRunner({
|
|||||||
expect(reservationsPostDeleted).toEqual([])
|
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 =
|
const inventoryLevelPreDelete =
|
||||||
await service.retrieveInventoryLevelByItemAndLocation(
|
await service.retrieveInventoryLevelByItemAndLocation(
|
||||||
inventoryItem.id,
|
inventoryItem.id,
|
||||||
@@ -570,6 +617,7 @@ moduleIntegrationTestRunner({
|
|||||||
|
|
||||||
describe("deleteInventoryItemLevelByLocationId", () => {
|
describe("deleteInventoryItemLevelByLocationId", () => {
|
||||||
let inventoryItem: InventoryItemDTO
|
let inventoryItem: InventoryItemDTO
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
inventoryItem = await service.create({
|
inventoryItem = await service.create({
|
||||||
sku: "test-sku",
|
sku: "test-sku",
|
||||||
@@ -581,7 +629,6 @@ moduleIntegrationTestRunner({
|
|||||||
inventory_item_id: inventoryItem.id,
|
inventory_item_id: inventoryItem.id,
|
||||||
location_id: "location-1",
|
location_id: "location-1",
|
||||||
stocked_quantity: 2,
|
stocked_quantity: 2,
|
||||||
reserved_quantity: 6,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
inventory_item_id: inventoryItem.id,
|
inventory_item_id: inventoryItem.id,
|
||||||
@@ -589,6 +636,12 @@ moduleIntegrationTestRunner({
|
|||||||
stocked_quantity: 2,
|
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 () => {
|
it("should remove inventory levels with given location id", async () => {
|
||||||
@@ -601,6 +654,7 @@ moduleIntegrationTestRunner({
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
stocked_quantity: 2,
|
stocked_quantity: 2,
|
||||||
location_id: "location-1",
|
location_id: "location-1",
|
||||||
|
reserved_quantity: 1,
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
stocked_quantity: 2,
|
stocked_quantity: 2,
|
||||||
@@ -741,6 +795,7 @@ moduleIntegrationTestRunner({
|
|||||||
|
|
||||||
describe("retrieveAvailableQuantity", () => {
|
describe("retrieveAvailableQuantity", () => {
|
||||||
let inventoryItem: InventoryItemDTO
|
let inventoryItem: InventoryItemDTO
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
inventoryItem = await service.create({
|
inventoryItem = await service.create({
|
||||||
sku: "test-sku",
|
sku: "test-sku",
|
||||||
@@ -761,7 +816,6 @@ moduleIntegrationTestRunner({
|
|||||||
inventory_item_id: inventoryItem.id,
|
inventory_item_id: inventoryItem.id,
|
||||||
location_id: "location-2",
|
location_id: "location-2",
|
||||||
stocked_quantity: 4,
|
stocked_quantity: 4,
|
||||||
reserved_quantity: 2,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
inventory_item_id: inventoryItem1.id,
|
inventory_item_id: inventoryItem1.id,
|
||||||
@@ -774,6 +828,13 @@ moduleIntegrationTestRunner({
|
|||||||
stocked_quantity: 3,
|
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 () => {
|
it("should calculate current stocked quantity across locations", async () => {
|
||||||
@@ -781,17 +842,20 @@ moduleIntegrationTestRunner({
|
|||||||
inventoryItem.id,
|
inventoryItem.id,
|
||||||
["location-1", "location-2"]
|
["location-1", "location-2"]
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(level).toEqual(6)
|
expect(level).toEqual(6)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("retrieveStockedQuantity", () => {
|
describe("retrieveStockedQuantity", () => {
|
||||||
let inventoryItem: InventoryItemDTO
|
let inventoryItem: InventoryItemDTO
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
inventoryItem = await service.create({
|
inventoryItem = await service.create({
|
||||||
sku: "test-sku",
|
sku: "test-sku",
|
||||||
origin_country: "test-country",
|
origin_country: "test-country",
|
||||||
})
|
})
|
||||||
|
|
||||||
const inventoryItem1 = await service.create({
|
const inventoryItem1 = await service.create({
|
||||||
sku: "test-sku-1",
|
sku: "test-sku-1",
|
||||||
origin_country: "test-country",
|
origin_country: "test-country",
|
||||||
@@ -807,7 +871,6 @@ moduleIntegrationTestRunner({
|
|||||||
inventory_item_id: inventoryItem.id,
|
inventory_item_id: inventoryItem.id,
|
||||||
location_id: "location-2",
|
location_id: "location-2",
|
||||||
stocked_quantity: 4,
|
stocked_quantity: 4,
|
||||||
reserved_quantity: 2,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
inventory_item_id: inventoryItem1.id,
|
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(
|
const stockedQuantity = await service.retrieveStockedQuantity(
|
||||||
inventoryItem.id,
|
inventoryItem.id,
|
||||||
["location-1", "location-2"]
|
["location-1", "location-2"]
|
||||||
@@ -831,13 +894,16 @@ moduleIntegrationTestRunner({
|
|||||||
expect(stockedQuantity).toEqual(8)
|
expect(stockedQuantity).toEqual(8)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("retrieveReservedQuantity", () => {
|
describe("retrieveReservedQuantity", () => {
|
||||||
let inventoryItem: InventoryItemDTO
|
let inventoryItem: InventoryItemDTO
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
inventoryItem = await service.create({
|
inventoryItem = await service.create({
|
||||||
sku: "test-sku",
|
sku: "test-sku",
|
||||||
origin_country: "test-country",
|
origin_country: "test-country",
|
||||||
})
|
})
|
||||||
|
|
||||||
const inventoryItem1 = await service.create({
|
const inventoryItem1 = await service.create({
|
||||||
sku: "test-sku-1",
|
sku: "test-sku-1",
|
||||||
origin_country: "test-country",
|
origin_country: "test-country",
|
||||||
@@ -853,19 +919,34 @@ moduleIntegrationTestRunner({
|
|||||||
inventory_item_id: inventoryItem.id,
|
inventory_item_id: inventoryItem.id,
|
||||||
location_id: "location-2",
|
location_id: "location-2",
|
||||||
stocked_quantity: 4,
|
stocked_quantity: 4,
|
||||||
reserved_quantity: 2,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
inventory_item_id: inventoryItem1.id,
|
inventory_item_id: inventoryItem1.id,
|
||||||
location_id: "location-1",
|
location_id: "location-1",
|
||||||
stocked_quantity: 3,
|
stocked_quantity: 3,
|
||||||
reserved_quantity: 2,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
inventory_item_id: inventoryItem.id,
|
inventory_item_id: inventoryItem.id,
|
||||||
location_id: "location-3",
|
location_id: "location-3",
|
||||||
stocked_quantity: 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", () => {
|
describe("confirmInventory", () => {
|
||||||
let inventoryItem: InventoryItemDTO
|
let inventoryItem: InventoryItemDTO
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
inventoryItem = await service.create({
|
inventoryItem = await service.create({
|
||||||
sku: "test-sku",
|
sku: "test-sku",
|
||||||
origin_country: "test-country",
|
origin_country: "test-country",
|
||||||
})
|
})
|
||||||
|
|
||||||
await service.createInventoryLevels([
|
await service.createInventoryLevels({
|
||||||
{
|
inventory_item_id: inventoryItem.id,
|
||||||
inventory_item_id: inventoryItem.id,
|
location_id: "location-1",
|
||||||
location_id: "location-1",
|
stocked_quantity: 4,
|
||||||
stocked_quantity: 4,
|
})
|
||||||
},
|
|
||||||
{
|
await service.createReservationItems({
|
||||||
inventory_item_id: inventoryItem.id,
|
inventory_item_id: inventoryItem.id,
|
||||||
location_id: "location-2",
|
location_id: "location-1",
|
||||||
stocked_quantity: 4,
|
quantity: 2,
|
||||||
reserved_quantity: 2,
|
})
|
||||||
},
|
|
||||||
])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should return true if quantity is less than or equal to available quantity", async () => {
|
it("should return true if quantity is less than or equal to available quantity", async () => {
|
||||||
const reservedQuantity = await service.confirmInventory(
|
const reservedQuantity = await service.confirmInventory(
|
||||||
inventoryItem.id,
|
inventoryItem.id,
|
||||||
"location-1",
|
["location-1"],
|
||||||
2
|
2
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(reservedQuantity).toBeTruthy()
|
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(
|
const reservedQuantity = await service.confirmInventory(
|
||||||
inventoryItem.id,
|
inventoryItem.id,
|
||||||
"location-1",
|
["location-1"],
|
||||||
3
|
3
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(reservedQuantity).toBeTruthy()
|
expect(reservedQuantity).toBeFalsy()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import InventoryService from "./services/inventory"
|
|
||||||
import { ModuleExports } from "@medusajs/types"
|
import { ModuleExports } from "@medusajs/types"
|
||||||
|
import InventoryService from "./services/inventory-module"
|
||||||
|
|
||||||
const service = InventoryService
|
const service = InventoryService
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export { default as InventoryModuleService } from "./inventory"
|
|
||||||
export { default as InventoryLevelService } from "./inventory-level"
|
export { default as InventoryLevelService } from "./inventory-level"
|
||||||
|
export { default as InventoryModuleService } from "./inventory-module"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
MedusaContext,
|
MedusaContext,
|
||||||
MedusaError,
|
MedusaError,
|
||||||
ModulesSdkUtils,
|
ModulesSdkUtils,
|
||||||
|
arrayDifference,
|
||||||
isDefined,
|
isDefined,
|
||||||
isString,
|
isString,
|
||||||
partitionArray,
|
partitionArray,
|
||||||
@@ -151,6 +152,63 @@ export default class InventoryModuleService<
|
|||||||
return inventoryLevels
|
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(
|
async createReservationItems(
|
||||||
input: InventoryNext.CreateReservationItemInput[],
|
input: InventoryNext.CreateReservationItemInput[],
|
||||||
context?: Context
|
context?: Context
|
||||||
@@ -171,27 +229,12 @@ export default class InventoryModuleService<
|
|||||||
InventoryNext.ReservationItemDTO[] | InventoryNext.ReservationItemDTO
|
InventoryNext.ReservationItemDTO[] | InventoryNext.ReservationItemDTO
|
||||||
> {
|
> {
|
||||||
const toCreate = Array.isArray(input) ? input : [input]
|
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) => {
|
await this.ensureInventoryAvailability(sanitized, context)
|
||||||
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)
|
|
||||||
|
|
||||||
const created = await this.createReservationItems_(toCreate, context)
|
const created = await this.createReservationItems_(toCreate, context)
|
||||||
|
|
||||||
@@ -289,8 +332,9 @@ export default class InventoryModuleService<
|
|||||||
): Promise<
|
): Promise<
|
||||||
InventoryNext.InventoryItemDTO | InventoryNext.InventoryItemDTO[]
|
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)
|
const result = await this.createInventoryItems_(toCreate, context)
|
||||||
|
|
||||||
context.messageAggregator?.saveRawMessageData(
|
context.messageAggregator?.saveRawMessageData(
|
||||||
@@ -340,7 +384,9 @@ export default class InventoryModuleService<
|
|||||||
): Promise<
|
): Promise<
|
||||||
InventoryNext.InventoryLevelDTO[] | InventoryNext.InventoryLevelDTO
|
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)
|
const created = await this.createInventoryLevels_(toCreate, context)
|
||||||
|
|
||||||
@@ -398,7 +444,9 @@ export default class InventoryModuleService<
|
|||||||
): Promise<
|
): Promise<
|
||||||
InventoryNext.InventoryItemDTO | InventoryNext.InventoryItemDTO[]
|
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)
|
const result = await this.updateInventoryItems_(updates, context)
|
||||||
|
|
||||||
@@ -508,7 +556,9 @@ export default class InventoryModuleService<
|
|||||||
): Promise<
|
): Promise<
|
||||||
InventoryNext.InventoryLevelDTO | InventoryNext.InventoryLevelDTO[]
|
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)
|
const levels = await this.updateInventoryLevels_(input, context)
|
||||||
|
|
||||||
@@ -593,7 +643,6 @@ export default class InventoryModuleService<
|
|||||||
InventoryNext.ReservationItemDTO | InventoryNext.ReservationItemDTO[]
|
InventoryNext.ReservationItemDTO | InventoryNext.ReservationItemDTO[]
|
||||||
> {
|
> {
|
||||||
const update = Array.isArray(input) ? input : [input]
|
const update = Array.isArray(input) ? input : [input]
|
||||||
|
|
||||||
const result = await this.updateReservationItems_(update, context)
|
const result = await this.updateReservationItems_(update, context)
|
||||||
|
|
||||||
context.messageAggregator?.saveRawMessageData(
|
context.messageAggregator?.saveRawMessageData(
|
||||||
@@ -621,26 +670,47 @@ export default class InventoryModuleService<
|
|||||||
input: (InventoryNext.UpdateReservationItemInput & { id: string })[],
|
input: (InventoryNext.UpdateReservationItemInput & { id: string })[],
|
||||||
@MedusaContext() context: Context = {}
|
@MedusaContext() context: Context = {}
|
||||||
): Promise<TReservationItem[]> {
|
): Promise<TReservationItem[]> {
|
||||||
|
const ids = input.map((u) => u.id)
|
||||||
const reservationItems = await this.listReservationItems(
|
const reservationItems = await this.listReservationItems(
|
||||||
{ id: input.map((u) => u.id) },
|
{ id: ids },
|
||||||
{},
|
{},
|
||||||
context
|
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(
|
const reservationMap: Map<string, ReservationItemDTO> = new Map(
|
||||||
reservationItems.map((r) => [r.id, r])
|
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(
|
const adjustments: Map<string, Map<string, number>> = input.reduce(
|
||||||
(acc, update) => {
|
(acc, update) => {
|
||||||
const reservation = reservationMap.get(update.id)
|
const reservation = reservationMap.get(update.id)!
|
||||||
if (!reservation) {
|
|
||||||
throw new MedusaError(
|
|
||||||
MedusaError.Types.INVALID_DATA,
|
|
||||||
`Reservation item with id ${update.id} not found`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const locationMap = acc.get(reservation.inventory_item_id) ?? new Map()
|
const locationMap = acc.get(reservation.inventory_item_id) ?? new Map()
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -676,6 +746,7 @@ export default class InventoryModuleService<
|
|||||||
}
|
}
|
||||||
|
|
||||||
acc.set(reservation.inventory_item_id, locationMap)
|
acc.set(reservation.inventory_item_id, locationMap)
|
||||||
|
|
||||||
return acc
|
return acc
|
||||||
},
|
},
|
||||||
new Map()
|
new Map()
|
||||||
Reference in New Issue
Block a user