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", () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import InventoryService from "./services/inventory"
|
||||
import { ModuleExports } from "@medusajs/types"
|
||||
import InventoryService from "./services/inventory-module"
|
||||
|
||||
const service = InventoryService
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { default as InventoryModuleService } from "./inventory"
|
||||
export { default as InventoryLevelService } from "./inventory-level"
|
||||
export { default as InventoryModuleService } from "./inventory-module"
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user