diff --git a/integration-tests/plugins/__tests__/inventory/reservation-items/index.js b/integration-tests/plugins/__tests__/inventory/reservation-items/index.js new file mode 100644 index 0000000000..8c7536edca --- /dev/null +++ b/integration-tests/plugins/__tests__/inventory/reservation-items/index.js @@ -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", + }) + }) + }) +}) diff --git a/packages/inventory/src/services/inventory.ts b/packages/inventory/src/services/inventory.ts index 8bfa855ad0..b05454d54c 100644 --- a/packages/inventory/src/services/inventory.ts +++ b/packages/inventory/src/services/inventory.ts @@ -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 { + return await this.reservationItemService_ + .withTransaction(this.activeManager_) + .retrieve(reservationId) + } + /** * Creates a reservation item * @param input - the input object diff --git a/packages/medusa/src/api/routes/admin/reservations/create-reservation.ts b/packages/medusa/src/api/routes/admin/reservations/create-reservation.ts index de6f70fac9..594528a9fb 100644 --- a/packages/medusa/src/api/routes/admin/reservations/create-reservation.ts +++ b/packages/medusa/src/api/routes/admin/reservations/create-reservation.ts @@ -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) diff --git a/packages/medusa/src/api/routes/admin/reservations/update-reservation.ts b/packages/medusa/src/api/routes/admin/reservations/update-reservation.ts index bd6ce32c25..e0c9a5db0a 100644 --- a/packages/medusa/src/api/routes/admin/reservations/update-reservation.ts +++ b/packages/medusa/src/api/routes/admin/reservations/update-reservation.ts @@ -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) diff --git a/packages/medusa/src/api/routes/admin/reservations/utils/validate-reservation-quantity.ts b/packages/medusa/src/api/routes/admin/reservations/utils/validate-reservation-quantity.ts new file mode 100644 index 0000000000..09c5b499d5 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/reservations/utils/validate-reservation-quantity.ts @@ -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" + ) + } +} diff --git a/packages/medusa/src/interfaces/services/inventory.ts b/packages/medusa/src/interfaces/services/inventory.ts index b2452dc729..58f97fbe70 100644 --- a/packages/medusa/src/interfaces/services/inventory.ts +++ b/packages/medusa/src/interfaces/services/inventory.ts @@ -43,6 +43,8 @@ export interface IInventoryService { locationId: string ): Promise + retrieveReservationItem(reservationId: string): Promise + createReservationItem( input: CreateReservationItemInput ): Promise