From 5eb61fa0ef991b8a9fabb16c2c159e51b9541867 Mon Sep 17 00:00:00 2001 From: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com> Date: Tue, 28 Feb 2023 16:28:11 +0100 Subject: [PATCH] fix(medusa): update create fulfillment flow (#3172) * update create fulfillment flow * move transaction service creation close to where it's used * integration tests * fix feedback * use transformBody * add changeset --- .changeset/hip-otters-thank.md | 5 + .../__tests__/inventory/order/order.js | 266 ++++++++++++++++++ .../plugins/factories/simple-order-factory.ts | 48 +++- packages/medusa/src/api/routes/admin/index.js | 2 + .../routes/admin/orders/create-fulfillment.ts | 90 +++--- .../src/services/product-variant-inventory.ts | 6 +- 6 files changed, 369 insertions(+), 48 deletions(-) create mode 100644 .changeset/hip-otters-thank.md create mode 100644 integration-tests/plugins/__tests__/inventory/order/order.js diff --git a/.changeset/hip-otters-thank.md b/.changeset/hip-otters-thank.md new file mode 100644 index 0000000000000..d0e2a3ed0d8f8 --- /dev/null +++ b/.changeset/hip-otters-thank.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": minor +--- + +Add inventory management to create-fulfillment flow diff --git a/integration-tests/plugins/__tests__/inventory/order/order.js b/integration-tests/plugins/__tests__/inventory/order/order.js new file mode 100644 index 0000000000000..72ab304bcd3bf --- /dev/null +++ b/integration-tests/plugins/__tests__/inventory/order/order.js @@ -0,0 +1,266 @@ +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") +const cartSeeder = require("../../../helpers/cart-seeder") +const { simpleProductFactory } = require("../../../../api/factories") +const { simpleSalesChannelFactory } = require("../../../../api/factories") +const { + simpleOrderFactory, + simpleRegionFactory, +} = require("../../../factories") + +jest.setTimeout(30000) + +const adminHeaders = { headers: { Authorization: "Bearer test_token" } } + +describe("/store/carts", () => { + let express + let appContainer + let dbConnection + + const doAfterEach = async () => { + const db = useDb() + return await db.teardown() + } + + 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) + }) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + express.close() + }) + + afterEach(async () => { + jest.clearAllMocks() + const db = useDb() + return await db.teardown() + }) + + describe("POST /store/carts/:id", () => { + let order + let locationId + let invItemId + let variantId + let prodVarInventoryService + + beforeEach(async () => { + const api = useApi() + + prodVarInventoryService = appContainer.resolve( + "productVariantInventoryService" + ) + const inventoryService = appContainer.resolve("inventoryService") + const stockLocationService = appContainer.resolve("stockLocationService") + const salesChannelLocationService = appContainer.resolve( + "salesChannelLocationService" + ) + + const r = await simpleRegionFactory(dbConnection, {}) + await simpleSalesChannelFactory(dbConnection, { + id: "test-channel", + is_default: true, + }) + + await adminSeeder(dbConnection) + + const product = await simpleProductFactory(dbConnection, { + id: "product1", + sales_channels: [{ id: "test-channel" }], + }) + variantId = product.variants[0].id + + const sl = await stockLocationService.create({ name: "test-location" }) + + locationId = sl.id + + await salesChannelLocationService.associateLocation( + "test-channel", + locationId + ) + + const invItem = await inventoryService.createInventoryItem({ + sku: "test-sku", + }) + invItemId = invItem.id + + await prodVarInventoryService.attachInventoryItem(variantId, invItem.id) + + await inventoryService.createInventoryLevel({ + inventory_item_id: invItem.id, + location_id: locationId, + stocked_quantity: 1, + }) + + const { id: orderId } = 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/${orderId}`, adminHeaders) + order = orderRes.data.order + + const inventoryItem = await api.get( + `/admin/inventory-items/${invItem.id}`, + adminHeaders + ) + + expect(inventoryItem.data.inventory_item.location_levels[0]).toEqual( + expect.objectContaining({ + stocked_quantity: 1, + reserved_quantity: 0, + available_quantity: 1, + }) + ) + }) + + describe("Fulfillments", () => { + const lineItemId = "line-item-id" + it("Adjusts reservations on successful fulfillment with reservation", async () => { + const api = useApi() + + await prodVarInventoryService.reserveQuantity(variantId, 1, { + locationId: locationId, + lineItemId: order.items[0].id, + }) + + let inventoryItem = await api.get( + `/admin/inventory-items/${invItemId}`, + adminHeaders + ) + + expect(inventoryItem.data.inventory_item.location_levels[0]).toEqual( + expect.objectContaining({ + stocked_quantity: 1, + reserved_quantity: 1, + available_quantity: 0, + }) + ) + + const fulfillmentRes = await api.post( + `/admin/orders/${order.id}/fulfillment`, + { + items: [{ item_id: lineItemId, quantity: 1 }], + location_id: locationId, + }, + adminHeaders + ) + + expect(fulfillmentRes.status).toBe(200) + expect(fulfillmentRes.data.order.fulfillment_status).toBe( + "partially_fulfilled" + ) + + inventoryItem = await api.get( + `/admin/inventory-items/${invItemId}`, + adminHeaders + ) + + const reservations = await api.get( + `/admin/reservations?inventory_item_id[]=${invItemId}`, + adminHeaders + ) + + expect(reservations.data.reservations.length).toBe(0) + expect(inventoryItem.data.inventory_item.location_levels[0]).toEqual( + expect.objectContaining({ + stocked_quantity: 0, + reserved_quantity: 0, + available_quantity: 0, + }) + ) + }) + + it("adjusts inventory levels on successful fulfillment without reservation", async () => { + const api = useApi() + + const fulfillmentRes = await api.post( + `/admin/orders/${order.id}/fulfillment`, + { + items: [{ item_id: lineItemId, quantity: 1 }], + location_id: locationId, + }, + adminHeaders + ) + expect(fulfillmentRes.status).toBe(200) + expect(fulfillmentRes.data.order.fulfillment_status).toBe( + "partially_fulfilled" + ) + + const inventoryItem = await api.get( + `/admin/inventory-items/${invItemId}`, + adminHeaders + ) + + expect(inventoryItem.data.inventory_item.location_levels[0]).toEqual( + expect.objectContaining({ + stocked_quantity: 0, + reserved_quantity: 0, + available_quantity: 0, + }) + ) + }) + + it("Fails to create fulfillment if there is not enough inventory at the fulfillment location", async () => { + const api = useApi() + + const err = await api + .post( + `/admin/orders/${order.id}/fulfillment`, + { + items: [{ item_id: lineItemId, quantity: 2 }], + location_id: locationId, + }, + adminHeaders + ) + .catch((e) => e) + + expect(err.response.status).toBe(400) + expect(err.response.data).toEqual({ + type: "not_allowed", + message: `Insufficient stock for item: ${order.items[0].title}`, + }) + + const inventoryItem = await api.get( + `/admin/inventory-items/${invItemId}`, + adminHeaders + ) + + expect(inventoryItem.data.inventory_item.location_levels[0]).toEqual( + expect.objectContaining({ + stocked_quantity: 1, + reserved_quantity: 0, + available_quantity: 1, + }) + ) + }) + }) + }) +}) diff --git a/integration-tests/plugins/factories/simple-order-factory.ts b/integration-tests/plugins/factories/simple-order-factory.ts index 4153aa0fe2b3c..052878bb2cfa0 100644 --- a/integration-tests/plugins/factories/simple-order-factory.ts +++ b/integration-tests/plugins/factories/simple-order-factory.ts @@ -5,6 +5,9 @@ import { Order, PaymentStatus, FulfillmentStatus, + SalesChannel, + Discount, + isString, } from "@medusajs/medusa" import { @@ -24,6 +27,11 @@ import { ShippingMethodFactoryData, simpleShippingMethodFactory, } from "./simple-shipping-method-factory" +import { + SalesChannelFactoryData, + simpleSalesChannelFactory, +} from "../../api/factories" +import { isDefined } from "medusa-core-utils" export type OrderFactoryData = { id?: string @@ -33,6 +41,7 @@ export type OrderFactoryData = { email?: string | null currency_code?: string tax_rate?: number | null + sales_channel?: string | SalesChannelFactoryData line_items?: LineItemFactoryData[] discounts?: DiscountFactoryData[] shipping_address?: AddressFactoryData @@ -72,15 +81,14 @@ export const simpleOrderFactory = async ( }) const customer = await manager.save(customerToSave) - let discounts = [] + let discounts: Discount[] = [] if (typeof data.discounts !== "undefined") { discounts = await Promise.all( data.discounts.map((d) => simpleDiscountFactory(connection, d, seed)) ) } - const id = data.id || `simple-order-${Math.random() * 1000}` - const toSave = manager.create(Order, { + const toCreate: Partial = { id, discounts, payment_status: data.payment_status ?? PaymentStatus.AWAITING, @@ -92,16 +100,44 @@ export const simpleOrderFactory = async ( currency_code: currencyCode, tax_rate: taxRate, shipping_address_id: address.id, - }) + } + + let sc_id + if (isDefined(data.sales_channel)) { + let sc + + if (isString(data.sales_channel)) { + sc = await manager.findOne(SalesChannel, { + where: { id: data.sales_channel }, + }) + } + + if (!sc) { + sc = await simpleSalesChannelFactory( + connection, + isString(data.sales_channel) + ? { id: data.sales_channel } + : data.sales_channel + ) + } + + sc_id = sc.id + } + + if (sc_id) { + toCreate.sales_channel_id = sc_id + } + + const toSave = manager.create(Order, toCreate) - const order = await manager.save(toSave) + const order = await manager.save(Order, toSave) const shippingMethods = data.shipping_methods || [] for (const sm of shippingMethods) { await simpleShippingMethodFactory(connection, { ...sm, order_id: order.id }) } - const items = data.line_items + const items = data.line_items || [] for (const item of items) { await simpleLineItemFactory(connection, { ...item, order_id: id }) } diff --git a/packages/medusa/src/api/routes/admin/index.js b/packages/medusa/src/api/routes/admin/index.js index 3f4e173f063fb..855e5ed084717 100644 --- a/packages/medusa/src/api/routes/admin/index.js +++ b/packages/medusa/src/api/routes/admin/index.js @@ -27,6 +27,7 @@ import regionRoutes from "./regions" import reservationRoutes from "./reservations" import returnReasonRoutes from "./return-reasons" import returnRoutes from "./returns" +import reservationRoutes from "./reservations" import salesChannelRoutes from "./sales-channels" import shippingOptionRoutes from "./shipping-options" import shippingProfileRoutes from "./shipping-profiles" @@ -101,6 +102,7 @@ export default (app, container, config) => { reservationRoutes(route) returnReasonRoutes(route) returnRoutes(route) + reservationRoutes(route) salesChannelRoutes(route) shippingOptionRoutes(route, featureFlagRouter) shippingProfileRoutes(route) diff --git a/packages/medusa/src/api/routes/admin/orders/create-fulfillment.ts b/packages/medusa/src/api/routes/admin/orders/create-fulfillment.ts index 8b4f42b8ba077..0546da1da03cf 100644 --- a/packages/medusa/src/api/routes/admin/orders/create-fulfillment.ts +++ b/packages/medusa/src/api/routes/admin/orders/create-fulfillment.ts @@ -97,39 +97,49 @@ import { FindParams } from "../../../../types/common" export default async (req, res) => { const { id } = req.params - const validated = req.validatedBody + const { validatedBody } = req as { + validatedBody: AdminPostOrdersOrderFulfillmentsReq + } const orderService: OrderService = req.scope.resolve("orderService") const pvInventoryService: ProductVariantInventoryService = req.scope.resolve( "productVariantInventoryService" ) + const manager: EntityManager = req.scope.resolve("manager") await manager.transaction(async (transactionManager) => { - const { fulfillments: existingFulfillments } = await orderService - .withTransaction(transactionManager) - .retrieve(id, { + const orderServiceTx = orderService.withTransaction(transactionManager) + + const { fulfillments: existingFulfillments } = + await orderServiceTx.retrieve(id, { relations: ["fulfillments"], }) const existingFulfillmentMap = new Map( existingFulfillments.map((fulfillment) => [fulfillment.id, fulfillment]) ) - const { fulfillments } = await orderService - .withTransaction(transactionManager) - .createFulfillment(id, validated.items, { - metadata: validated.metadata, - no_notification: validated.no_notification, + await orderServiceTx.createFulfillment(id, validatedBody.items, { + metadata: validatedBody.metadata, + no_notification: validatedBody.no_notification, + }) + + if (validatedBody.location_id) { + const { fulfillments } = await orderServiceTx.retrieve(id, { + relations: [ + "fulfillments", + "fulfillments.items", + "fulfillments.items.item", + ], }) - const pvInventoryServiceTx = - pvInventoryService.withTransaction(transactionManager) + const pvInventoryServiceTx = + pvInventoryService.withTransaction(transactionManager) - if (validated.location_id) { await updateInventoryAndReservations( fulfillments.filter((f) => !existingFulfillmentMap[f.id]), { inventoryService: pvInventoryServiceTx, - locationId: validated.location_id, + locationId: validatedBody.location_id, } ) } @@ -151,33 +161,35 @@ const updateInventoryAndReservations = async ( ) => { const { inventoryService, locationId } = context - fulfillments.map(async ({ items }) => { - await inventoryService.validateInventoryAtLocation( - items.map(({ item, quantity }) => ({ ...item, quantity } as LineItem)), - locationId - ) - - await Promise.all( - items.map(async ({ item, quantity }) => { - if (!item.variant_id) { - return - } + await Promise.all( + fulfillments.map(async ({ items }) => { + await inventoryService.validateInventoryAtLocation( + items.map(({ item, quantity }) => ({ ...item, quantity } as LineItem)), + locationId + ) - await inventoryService.adjustReservationsQuantityByLineItem( - item.id, - item.variant_id, - locationId, - -quantity - ) - - await inventoryService.adjustInventory( - item.variant_id, - locationId, - -quantity - ) - }) - ) - }) + await Promise.all( + items.map(async ({ item, quantity }) => { + if (!item.variant_id) { + return + } + + await inventoryService.adjustReservationsQuantityByLineItem( + item.id, + item.variant_id, + locationId, + -quantity + ) + + await inventoryService.adjustInventory( + item.variant_id, + locationId, + -quantity + ) + }) + ) + }) + ) } /** diff --git a/packages/medusa/src/services/product-variant-inventory.ts b/packages/medusa/src/services/product-variant-inventory.ts index 0a33d39558038..240da4c337516 100644 --- a/packages/medusa/src/services/product-variant-inventory.ts +++ b/packages/medusa/src/services/product-variant-inventory.ts @@ -447,7 +447,7 @@ class ProductVariantInventoryService extends TransactionBaseService { ) const reservationQtyUpdate = - reservation.quantity - + reservation.quantity + quantity * productVariantInventory.required_quantity if (reservationQtyUpdate === 0) { @@ -494,11 +494,11 @@ class ProductVariantInventoryService extends TransactionBaseService { ) for (const inventoryLevel of inventoryLevels) { - const pvInventoryItem = pviMap[inventoryLevel.inventory_item_id] + const pvInventoryItem = pviMap.get(inventoryLevel.inventory_item_id) if ( !pvInventoryItem || - pvInventoryItem.quantity * item.quantity > + pvInventoryItem.required_quantity * item.quantity > inventoryLevel.stocked_quantity ) { throw new MedusaError(