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:
Philip Korsholm
2023-03-16 10:15:39 +01:00
committed by GitHub
parent 061a600f80
commit 38c8d49f46
6 changed files with 281 additions and 0 deletions

View File

@@ -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",
})
})
})
})

View File

@@ -152,6 +152,17 @@ export default class InventoryService
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
* @param input - the input object

View File

@@ -1,6 +1,8 @@
import { IsNumber, IsObject, IsOptional, IsString } from "class-validator"
import { isDefined } from "medusa-core-utils"
import { EntityManager } from "typeorm"
import { IInventoryService } from "../../../../interfaces"
import { validateUpdateReservationQuantity } from "./utils/validate-reservation-quantity"
/**
* @oas [post] /admin/reservations
@@ -69,6 +71,17 @@ export default async (req, res) => {
const inventoryService: IInventoryService =
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) => {
return await inventoryService
.withTransaction(manager)

View File

@@ -1,6 +1,9 @@
import { IsNumber, IsObject, IsOptional, IsString } from "class-validator"
import { isDefined, MedusaError } from "medusa-core-utils"
import { EntityManager } from "typeorm"
import { IInventoryService } from "../../../../interfaces"
import { LineItemService } from "../../../../services"
import { validateUpdateReservationQuantity } from "./utils/validate-reservation-quantity"
/**
* @oas [post] /admin/reservations/{id}
@@ -69,10 +72,24 @@ export default async (req, res) => {
}
const manager: EntityManager = req.scope.resolve("manager")
const lineItemService: LineItemService = req.scope.resolve("lineItemService")
const inventoryService: IInventoryService =
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) => {
await inventoryService
.withTransaction(manager)

View File

@@ -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"
)
}
}

View File

@@ -43,6 +43,8 @@ export interface IInventoryService {
locationId: string
): Promise<InventoryLevelDTO>
retrieveReservationItem(reservationId: string): Promise<ReservationItemDTO>
createReservationItem(
input: CreateReservationItemInput
): Promise<ReservationItemDTO>