Feat(medusa): handle reservation quantity update for line items (#3484)
**What** - Raise exception if a reservation is updated or created to have larger quantity than is unfulfilled for a line-item Fixes CORE-1249
This commit is contained in:
@@ -0,0 +1,207 @@
|
|||||||
|
const path = require("path")
|
||||||
|
|
||||||
|
const { bootstrapApp } = require("../../../../helpers/bootstrap-app")
|
||||||
|
const { initDb, useDb } = require("../../../../helpers/use-db")
|
||||||
|
const { setPort, useApi } = require("../../../../helpers/use-api")
|
||||||
|
|
||||||
|
const adminSeeder = require("../../../helpers/admin-seeder")
|
||||||
|
|
||||||
|
jest.setTimeout(30000)
|
||||||
|
|
||||||
|
const {
|
||||||
|
simpleProductFactory,
|
||||||
|
simpleOrderFactory,
|
||||||
|
simpleRegionFactory,
|
||||||
|
} = require("../../../factories")
|
||||||
|
const { simpleSalesChannelFactory } = require("../../../../api/factories")
|
||||||
|
const adminHeaders = { headers: { Authorization: "Bearer test_token" } }
|
||||||
|
|
||||||
|
describe("Inventory Items endpoints", () => {
|
||||||
|
let appContainer
|
||||||
|
let dbConnection
|
||||||
|
let express
|
||||||
|
|
||||||
|
let inventoryItem
|
||||||
|
let locationId
|
||||||
|
|
||||||
|
let prodVarInventoryService
|
||||||
|
let inventoryService
|
||||||
|
let stockLocationService
|
||||||
|
let salesChannelLocationService
|
||||||
|
|
||||||
|
let reg
|
||||||
|
let regionId
|
||||||
|
let order
|
||||||
|
let variantId
|
||||||
|
let reservationItem
|
||||||
|
let lineItemId
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
|
||||||
|
dbConnection = await initDb({ cwd })
|
||||||
|
const { container, app, port } = await bootstrapApp({ cwd })
|
||||||
|
appContainer = container
|
||||||
|
|
||||||
|
setPort(port)
|
||||||
|
express = app.listen(port, (err) => {
|
||||||
|
process.send(port)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
await adminSeeder(dbConnection)
|
||||||
|
|
||||||
|
prodVarInventoryService = appContainer.resolve(
|
||||||
|
"productVariantInventoryService"
|
||||||
|
)
|
||||||
|
inventoryService = appContainer.resolve("inventoryService")
|
||||||
|
stockLocationService = appContainer.resolve("stockLocationService")
|
||||||
|
salesChannelLocationService = appContainer.resolve(
|
||||||
|
"salesChannelLocationService"
|
||||||
|
)
|
||||||
|
|
||||||
|
const r = await simpleRegionFactory(dbConnection, {})
|
||||||
|
regionId = r.id
|
||||||
|
await simpleSalesChannelFactory(dbConnection, {
|
||||||
|
id: "test-channel",
|
||||||
|
is_default: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
await simpleProductFactory(dbConnection, {
|
||||||
|
id: "product1",
|
||||||
|
sales_channels: [{ id: "test-channel" }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const productRes = await api.get(`/admin/products/product1`, adminHeaders)
|
||||||
|
|
||||||
|
variantId = productRes.data.product.variants[0].id
|
||||||
|
|
||||||
|
const stockRes = await api.post(
|
||||||
|
`/admin/stock-locations`,
|
||||||
|
{
|
||||||
|
name: "Fake Warehouse",
|
||||||
|
},
|
||||||
|
adminHeaders
|
||||||
|
)
|
||||||
|
locationId = stockRes.data.stock_location.id
|
||||||
|
|
||||||
|
await salesChannelLocationService.associateLocation(
|
||||||
|
"test-channel",
|
||||||
|
locationId
|
||||||
|
)
|
||||||
|
|
||||||
|
inventoryItem = await inventoryService.createInventoryItem({
|
||||||
|
sku: "1234",
|
||||||
|
})
|
||||||
|
|
||||||
|
await prodVarInventoryService.attachInventoryItem(
|
||||||
|
variantId,
|
||||||
|
inventoryItem.id
|
||||||
|
)
|
||||||
|
|
||||||
|
await inventoryService.createInventoryLevel({
|
||||||
|
inventory_item_id: inventoryItem.id,
|
||||||
|
location_id: locationId,
|
||||||
|
stocked_quantity: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
order = await simpleOrderFactory(dbConnection, {
|
||||||
|
sales_channel: "test-channel",
|
||||||
|
line_items: [
|
||||||
|
{
|
||||||
|
variant_id: variantId,
|
||||||
|
quantity: 2,
|
||||||
|
id: "line-item-id",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
shipping_methods: [
|
||||||
|
{
|
||||||
|
shipping_option: {
|
||||||
|
region_id: r.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const orderRes = await api.get(`/admin/orders/${order.id}`, adminHeaders)
|
||||||
|
|
||||||
|
lineItemId = orderRes.data.order.items[0].id
|
||||||
|
|
||||||
|
reservationItem = await inventoryService.createReservationItem({
|
||||||
|
line_item_id: lineItemId,
|
||||||
|
inventory_item_id: inventoryItem.id,
|
||||||
|
location_id: locationId,
|
||||||
|
quantity: 2,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const db = useDb()
|
||||||
|
await db.shutdown()
|
||||||
|
express.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
const db = useDb()
|
||||||
|
return await db.teardown()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Reservation items", () => {
|
||||||
|
it("Create reservation item throws if available item quantity is less than reservation quantity", async () => {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const orderRes = await api.get(`/admin/orders/${order.id}`, adminHeaders)
|
||||||
|
|
||||||
|
expect(orderRes.data.order.items[0].quantity).toBe(2)
|
||||||
|
expect(orderRes.data.order.items[0].fulfilled_quantity).toBeFalsy()
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
quantity: 1,
|
||||||
|
inventory_item_id: inventoryItem.id,
|
||||||
|
line_item_id: lineItemId,
|
||||||
|
location_id: locationId,
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await api
|
||||||
|
.post(`/admin/reservations`, payload, adminHeaders)
|
||||||
|
.catch((err) => err)
|
||||||
|
|
||||||
|
expect(res.response.status).toBe(400)
|
||||||
|
expect(res.response.data).toEqual({
|
||||||
|
type: "invalid_data",
|
||||||
|
message:
|
||||||
|
"The reservation quantity cannot be greater than the unfulfilled line item quantity",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("Update reservation item throws if available item quantity is less than reservation quantity", async () => {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const orderRes = await api.get(`/admin/orders/${order.id}`, adminHeaders)
|
||||||
|
|
||||||
|
expect(orderRes.data.order.items[0].quantity).toBe(2)
|
||||||
|
expect(orderRes.data.order.items[0].fulfilled_quantity).toBeFalsy()
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
quantity: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await api
|
||||||
|
.post(
|
||||||
|
`/admin/reservations/${reservationItem.id}`,
|
||||||
|
payload,
|
||||||
|
adminHeaders
|
||||||
|
)
|
||||||
|
.catch((err) => err)
|
||||||
|
|
||||||
|
expect(res.response.status).toBe(400)
|
||||||
|
expect(res.response.data).toEqual({
|
||||||
|
type: "invalid_data",
|
||||||
|
message:
|
||||||
|
"The reservation quantity cannot be greater than the unfulfilled line item quantity",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -152,6 +152,17 @@ export default class InventoryService
|
|||||||
return inventoryLevel
|
return inventoryLevel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a reservation item
|
||||||
|
* @param inventoryItemId - the id of the reservation item
|
||||||
|
* @return the retrieved reservation level
|
||||||
|
*/
|
||||||
|
async retrieveReservationItem(reservationId: string): Promise<ReservationItemDTO> {
|
||||||
|
return await this.reservationItemService_
|
||||||
|
.withTransaction(this.activeManager_)
|
||||||
|
.retrieve(reservationId)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a reservation item
|
* Creates a reservation item
|
||||||
* @param input - the input object
|
* @param input - the input object
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { IsNumber, IsObject, IsOptional, IsString } from "class-validator"
|
import { IsNumber, IsObject, IsOptional, IsString } from "class-validator"
|
||||||
|
import { isDefined } from "medusa-core-utils"
|
||||||
import { EntityManager } from "typeorm"
|
import { EntityManager } from "typeorm"
|
||||||
import { IInventoryService } from "../../../../interfaces"
|
import { IInventoryService } from "../../../../interfaces"
|
||||||
|
import { validateUpdateReservationQuantity } from "./utils/validate-reservation-quantity"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @oas [post] /admin/reservations
|
* @oas [post] /admin/reservations
|
||||||
@@ -69,6 +71,17 @@ export default async (req, res) => {
|
|||||||
const inventoryService: IInventoryService =
|
const inventoryService: IInventoryService =
|
||||||
req.scope.resolve("inventoryService")
|
req.scope.resolve("inventoryService")
|
||||||
|
|
||||||
|
if (isDefined(validatedBody.line_item_id)) {
|
||||||
|
await validateUpdateReservationQuantity(
|
||||||
|
validatedBody.line_item_id,
|
||||||
|
validatedBody.quantity,
|
||||||
|
{
|
||||||
|
lineItemService: req.scope.resolve("lineItemService"),
|
||||||
|
inventoryService: req.scope.resolve("inventoryService"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const reservation = await manager.transaction(async (manager) => {
|
const reservation = await manager.transaction(async (manager) => {
|
||||||
return await inventoryService
|
return await inventoryService
|
||||||
.withTransaction(manager)
|
.withTransaction(manager)
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { IsNumber, IsObject, IsOptional, IsString } from "class-validator"
|
import { IsNumber, IsObject, IsOptional, IsString } from "class-validator"
|
||||||
|
import { isDefined, MedusaError } from "medusa-core-utils"
|
||||||
import { EntityManager } from "typeorm"
|
import { EntityManager } from "typeorm"
|
||||||
import { IInventoryService } from "../../../../interfaces"
|
import { IInventoryService } from "../../../../interfaces"
|
||||||
|
import { LineItemService } from "../../../../services"
|
||||||
|
import { validateUpdateReservationQuantity } from "./utils/validate-reservation-quantity"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @oas [post] /admin/reservations/{id}
|
* @oas [post] /admin/reservations/{id}
|
||||||
@@ -69,10 +72,24 @@ export default async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const manager: EntityManager = req.scope.resolve("manager")
|
const manager: EntityManager = req.scope.resolve("manager")
|
||||||
|
const lineItemService: LineItemService = req.scope.resolve("lineItemService")
|
||||||
|
|
||||||
const inventoryService: IInventoryService =
|
const inventoryService: IInventoryService =
|
||||||
req.scope.resolve("inventoryService")
|
req.scope.resolve("inventoryService")
|
||||||
|
|
||||||
|
const reservation = await inventoryService.retrieveReservationItem(id)
|
||||||
|
|
||||||
|
if (reservation.line_item_id && isDefined(validatedBody.quantity)) {
|
||||||
|
await validateUpdateReservationQuantity(
|
||||||
|
reservation.line_item_id,
|
||||||
|
validatedBody.quantity - reservation.quantity,
|
||||||
|
{
|
||||||
|
lineItemService,
|
||||||
|
inventoryService,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const result = await manager.transaction(async (manager) => {
|
const result = await manager.transaction(async (manager) => {
|
||||||
await inventoryService
|
await inventoryService
|
||||||
.withTransaction(manager)
|
.withTransaction(manager)
|
||||||
|
|||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
import { MedusaError } from "medusa-core-utils"
|
||||||
|
import { IInventoryService } from "../../../../../interfaces"
|
||||||
|
import { LineItemService } from "../../../../../services"
|
||||||
|
|
||||||
|
export const validateUpdateReservationQuantity = async (
|
||||||
|
lineItemId: string,
|
||||||
|
quantityUpdate: number,
|
||||||
|
context: {
|
||||||
|
lineItemService: LineItemService
|
||||||
|
inventoryService: IInventoryService
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const { lineItemService, inventoryService } = context
|
||||||
|
const [reservationItems] = await inventoryService.listReservationItems({
|
||||||
|
line_item_id: lineItemId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalQuantity = reservationItems.reduce(
|
||||||
|
(acc, cur) => acc + cur.quantity,
|
||||||
|
quantityUpdate
|
||||||
|
)
|
||||||
|
|
||||||
|
const lineItem = await lineItemService.retrieve(lineItemId)
|
||||||
|
|
||||||
|
if (totalQuantity > lineItem.quantity - (lineItem.fulfilled_quantity || 0)) {
|
||||||
|
throw new MedusaError(
|
||||||
|
MedusaError.Types.INVALID_DATA,
|
||||||
|
"The reservation quantity cannot be greater than the unfulfilled line item quantity"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,8 @@ export interface IInventoryService {
|
|||||||
locationId: string
|
locationId: string
|
||||||
): Promise<InventoryLevelDTO>
|
): Promise<InventoryLevelDTO>
|
||||||
|
|
||||||
|
retrieveReservationItem(reservationId: string): Promise<ReservationItemDTO>
|
||||||
|
|
||||||
createReservationItem(
|
createReservationItem(
|
||||||
input: CreateReservationItemInput
|
input: CreateReservationItemInput
|
||||||
): Promise<ReservationItemDTO>
|
): Promise<ReservationItemDTO>
|
||||||
|
|||||||
Reference in New Issue
Block a user