Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat(medusa): handle reservation quantity update for line items #3484

Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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",
})
})
})
})
11 changes: 11 additions & 0 deletions packages/inventory/src/services/inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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}
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
)
}
}
2 changes: 2 additions & 0 deletions packages/medusa/src/interfaces/services/inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export interface IInventoryService {
locationId: string
): Promise<InventoryLevelDTO>

retrieveReservationItem(reservationId: string): Promise<ReservationItemDTO>

createReservationItem(
input: CreateReservationItemInput
): Promise<ReservationItemDTO>
Expand Down