From 99c981e981a6a6c40cfd2bdc62c08e82ad41258f Mon Sep 17 00:00:00 2001 From: fPolic Date: Wed, 23 Oct 2024 14:11:46 +0200 Subject: [PATCH 1/4] fix: correctly update stock location when partial fulfillemnt is created --- .../http/__tests__/fixtures/order.ts | 7 +- .../http/__tests__/order/admin/order.spec.ts | 142 +++++++++++++++++- .../src/order/workflows/create-fulfillment.ts | 8 +- 3 files changed, 145 insertions(+), 12 deletions(-) diff --git a/integration-tests/http/__tests__/fixtures/order.ts b/integration-tests/http/__tests__/fixtures/order.ts index c1511b531a03c..c88614e43656c 100644 --- a/integration-tests/http/__tests__/fixtures/order.ts +++ b/integration-tests/http/__tests__/fixtures/order.ts @@ -22,7 +22,7 @@ export async function createOrderSeeder({ container: MedusaContainer productOverride?: AdminProduct stockChannelOverride?: AdminStockLocation - additionalProducts?: AdminProduct[] + additionalProducts?: { variant_id: string; quantity: number }[] inventoryItemOverride?: AdminInventoryItem }) { const publishableKey = await generatePublishableKey(container) @@ -195,10 +195,7 @@ export async function createOrderSeeder({ sales_channel_id: salesChannel.id, items: [ { quantity: 1, variant_id: product.variants[0].id }, - ...(additionalProducts || []).map((p) => ({ - quantity: 1, - variant_id: p.variants?.[0]?.id, - })), + ...(additionalProducts || []), ], }, storeHeaders diff --git a/integration-tests/http/__tests__/order/admin/order.spec.ts b/integration-tests/http/__tests__/order/admin/order.spec.ts index 9ee833bcb43fd..43a8ec0be5f62 100644 --- a/integration-tests/http/__tests__/order/admin/order.spec.ts +++ b/integration-tests/http/__tests__/order/admin/order.spec.ts @@ -7,11 +7,11 @@ import { import { setupTaxStructure } from "../../../../modules/__tests__/fixtures" import { createOrderSeeder } from "../../fixtures/order" -jest.setTimeout(30000) +jest.setTimeout(300000) medusaIntegrationTestRunner({ testSuite: ({ dbConnection, getContainer, api }) => { - let order, seeder + let order, seeder, inventoryItemOverride3, productOverride3 beforeEach(async () => { const container = getContainer() @@ -82,6 +82,14 @@ medusaIntegrationTestRunner({ ) ).data.inventory_item + inventoryItemOverride3 = ( + await api.post( + `/admin/inventory-items`, + { sku: "test-variant-3", requires_shipping: false }, + adminHeaders + ) + ).data.inventory_item + await api.post( `/admin/inventory-items/${inventoryItemOverride2.id}/location-levels`, { @@ -91,6 +99,15 @@ medusaIntegrationTestRunner({ adminHeaders ) + await api.post( + `/admin/inventory-items/${inventoryItemOverride3.id}/location-levels`, + { + location_id: stockChannelOverride.id, + stocked_quantity: 10, + }, + adminHeaders + ) + const productOverride2 = ( await api.post( "/admin/products", @@ -127,11 +144,50 @@ medusaIntegrationTestRunner({ ) ).data.product + productOverride3 = ( + await api.post( + "/admin/products", + { + title: `Test fixture 3`, + options: [ + { title: "size", values: ["large", "small"] }, + { title: "color", values: ["green"] }, + ], + variants: [ + { + title: "Test variant 3", + sku: "test-variant-3", + inventory_items: [ + { + inventory_item_id: inventoryItemOverride3.id, + required_quantity: 1, + }, + ], + prices: [ + { + currency_code: "usd", + amount: 100, + }, + ], + options: { + size: "small", + color: "green", + }, + }, + ], + }, + adminHeaders + ) + ).data.product + seeder = await createOrderSeeder({ api, container: getContainer(), productOverride, - additionalProducts: [productOverride2], + additionalProducts: [ + { variant_id: productOverride2.variants[0].id, quantity: 1 }, + { variant_id: productOverride3.variants[0].id, quantity: 3 }, + ], stockChannelOverride, inventoryItemOverride, }) @@ -157,6 +213,86 @@ medusaIntegrationTestRunner({ expect(response2.orders[0].email).toEqual(userEmail) }) + it("should update stock levels correctly when creating partial fulfillment on an order", async () => { + const orderItemId = order.items.find( + (i) => i.variant_id === productOverride3.variants[0].id + ).id + + let iitem = ( + await api.get( + `/admin/inventory-items/${inventoryItemOverride3.id}?fields=stocked_quantity,reserved_quantity`, + adminHeaders + ) + ).data.inventory_item + + expect(iitem.stocked_quantity).toBe(10) + expect(iitem.reserved_quantity).toBe(3) + + await api.post( + `/admin/orders/${order.id}/fulfillments`, + { + location_id: seeder.stockLocation.id, + items: [{ id: orderItemId, quantity: 1 }], + }, + adminHeaders + ) + + iitem = ( + await api.get( + `/admin/inventory-items/${inventoryItemOverride3.id}?fields=stocked_quantity,reserved_quantity`, + adminHeaders + ) + ).data.inventory_item + + expect(iitem.stocked_quantity).toBe(9) + expect(iitem.reserved_quantity).toBe(2) + + await api.post( + `/admin/orders/${order.id}/fulfillments`, + { + location_id: seeder.stockLocation.id, + items: [{ id: orderItemId, quantity: 1 }], + }, + adminHeaders + ) + + iitem = ( + await api.get( + `/admin/inventory-items/${inventoryItemOverride3.id}?fields=stocked_quantity,reserved_quantity`, + adminHeaders + ) + ).data.inventory_item + + expect(iitem.stocked_quantity).toBe(8) + expect(iitem.reserved_quantity).toBe(1) + + const { + data: { order: fulfillableOrder }, + } = await api.post( + `/admin/orders/${order.id}/fulfillments?fields=+fulfillments.id,fulfillments.quantity`, + { + location_id: seeder.stockLocation.id, + items: [{ id: orderItemId, quantity: 1 }], + }, + adminHeaders + ) + + iitem = ( + await api.get( + `/admin/inventory-items/${inventoryItemOverride3.id}?fields=stocked_quantity,reserved_quantity`, + adminHeaders + ) + ).data.inventory_item + + expect(iitem.stocked_quantity).toBe(7) + expect(iitem.reserved_quantity).toBe(0) + + expect(fulfillableOrder.fulfillments).toHaveLength(3) + expect( + fulfillableOrder.fulfillments.every((f) => f.quantity === 1) + ).toBe(true) + }) + it("should only create fulfillments grouped by shipping requirement", async () => { const { response: { data }, diff --git a/packages/core/core-flows/src/order/workflows/create-fulfillment.ts b/packages/core/core-flows/src/order/workflows/create-fulfillment.ts index 1a4697aed407e..a2beda91e893f 100644 --- a/packages/core/core-flows/src/order/workflows/create-fulfillment.ts +++ b/packages/core/core-flows/src/order/workflows/create-fulfillment.ts @@ -202,20 +202,20 @@ function prepareInventoryUpdate({ const inputQuantity = inputItemsMap[item.id]?.quantity ?? item.quantity - const quantity = reservation.quantity - inputQuantity + const remainingReservationQuantity = reservation.quantity - inputQuantity inventoryAdjustment.push({ inventory_item_id: reservation.inventory_item_id, location_id: input.location_id ?? reservation.location_id, - adjustment: MathBN.mult(item.quantity, -1), + adjustment: MathBN.mult(inputQuantity, -1), }) - if (quantity === 0) { + if (remainingReservationQuantity === 0) { toDelete.push(reservation.id) } else { toUpdate.push({ id: reservation.id, - quantity: quantity, + quantity: remainingReservationQuantity, location_id: input.location_id ?? reservation.location_id, }) } From 08753c9b5fe3154c20ff4c7fa8ffdb872c4cc7c9 Mon Sep 17 00:00:00 2001 From: fPolic Date: Wed, 23 Oct 2024 14:53:15 +0200 Subject: [PATCH 2/4] fix: update test --- .../http/__tests__/order/admin/order.spec.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/integration-tests/http/__tests__/order/admin/order.spec.ts b/integration-tests/http/__tests__/order/admin/order.spec.ts index 43a8ec0be5f62..1fb8c0d478519 100644 --- a/integration-tests/http/__tests__/order/admin/order.spec.ts +++ b/integration-tests/http/__tests__/order/admin/order.spec.ts @@ -269,7 +269,7 @@ medusaIntegrationTestRunner({ const { data: { order: fulfillableOrder }, } = await api.post( - `/admin/orders/${order.id}/fulfillments?fields=+fulfillments.id,fulfillments.quantity`, + `/admin/orders/${order.id}/fulfillments?fields=fulfillments.id`, { location_id: seeder.stockLocation.id, items: [{ id: orderItemId, quantity: 1 }], @@ -277,6 +277,8 @@ medusaIntegrationTestRunner({ adminHeaders ) + expect(fulfillableOrder.fulfillments).toHaveLength(3) + iitem = ( await api.get( `/admin/inventory-items/${inventoryItemOverride3.id}?fields=stocked_quantity,reserved_quantity`, @@ -286,11 +288,6 @@ medusaIntegrationTestRunner({ expect(iitem.stocked_quantity).toBe(7) expect(iitem.reserved_quantity).toBe(0) - - expect(fulfillableOrder.fulfillments).toHaveLength(3) - expect( - fulfillableOrder.fulfillments.every((f) => f.quantity === 1) - ).toBe(true) }) it("should only create fulfillments grouped by shipping requirement", async () => { From 82e6eb0976b33359a7fcbc8d67868cd0166782ce Mon Sep 17 00:00:00 2001 From: fPolic Date: Wed, 23 Oct 2024 15:28:33 +0200 Subject: [PATCH 3/4] fix: count reserved quantity of the item as available quantity for fulfillment --- .../order-create-fulfillment-form.tsx | 18 +++++++++++++++--- .../order-create-fulfillment-item.tsx | 10 ++++++++-- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/admin/dashboard/src/routes/orders/order-create-fulfillment/components/order-create-fulfillment-form/order-create-fulfillment-form.tsx b/packages/admin/dashboard/src/routes/orders/order-create-fulfillment/components/order-create-fulfillment-form/order-create-fulfillment-form.tsx index c47bf36b98eec..fc9d779d34263 100644 --- a/packages/admin/dashboard/src/routes/orders/order-create-fulfillment/components/order-create-fulfillment-form/order-create-fulfillment-form.tsx +++ b/packages/admin/dashboard/src/routes/orders/order-create-fulfillment/components/order-create-fulfillment-form/order-create-fulfillment-form.tsx @@ -1,5 +1,5 @@ import { zodResolver } from "@hookform/resolvers/zod" -import { useEffect, useState } from "react" +import { useEffect, useMemo, useState } from "react" import { useTranslation } from "react-i18next" import * as zod from "zod" @@ -8,7 +8,6 @@ import { Alert, Button, Select, Switch, toast } from "@medusajs/ui" import { useForm, useWatch } from "react-hook-form" import { OrderLineItemDTO } from "@medusajs/types" -import { useSearchParams } from "react-router-dom" import { Form } from "../../../../../components/common/form" import { RouteFocusModal, @@ -20,6 +19,7 @@ import { useStockLocations } from "../../../../../hooks/api/stock-locations" import { getFulfillableQuantity } from "../../../../../lib/order-item" import { CreateFulfillmentSchema } from "./constants" import { OrderCreateFulfillmentItem } from "./order-create-fulfillment-item" +import { useReservationItems } from "../../../../../hooks/api" type OrderCreateFulfillmentFormProps = { order: AdminOrder @@ -32,11 +32,20 @@ export function OrderCreateFulfillmentForm({ }: OrderCreateFulfillmentFormProps) { const { t } = useTranslation() const { handleSuccess } = useRouteModal() - const [searchParams] = useSearchParams() const { mutateAsync: createOrderFulfillment, isPending: isMutating } = useCreateOrderFulfillment(order.id) + const { reservations } = useReservationItems({ + line_item_id: order.items.map((i) => i.id), + }) + + const itemReservedQuantitiesMap = useMemo( + () => + new Map((reservations || []).map((r) => [r.line_item_id, r.quantity])), + [reservations] + ) + const [fulfillableItems, setFulfillableItems] = useState(() => (order.items || []).filter( (item) => @@ -246,6 +255,9 @@ export function OrderCreateFulfillmentForm({ form={form} item={item} locationId={selectedLocationId} + itemReservedQuantitiesMap={ + itemReservedQuantitiesMap + } /> ) })} diff --git a/packages/admin/dashboard/src/routes/orders/order-create-fulfillment/components/order-create-fulfillment-form/order-create-fulfillment-item.tsx b/packages/admin/dashboard/src/routes/orders/order-create-fulfillment/components/order-create-fulfillment-form/order-create-fulfillment-item.tsx index 5a88293ff08c7..091b082f8ae07 100644 --- a/packages/admin/dashboard/src/routes/orders/order-create-fulfillment/components/order-create-fulfillment-form/order-create-fulfillment-item.tsx +++ b/packages/admin/dashboard/src/routes/orders/order-create-fulfillment/components/order-create-fulfillment-form/order-create-fulfillment-item.tsx @@ -10,12 +10,14 @@ import { Thumbnail } from "../../../../../components/common/thumbnail/index" import { useProductVariant } from "../../../../../hooks/api/products" import { getFulfillableQuantity } from "../../../../../lib/order-item" import { CreateFulfillmentSchema } from "./constants" +import { useReservationItems } from "../../../../../hooks/api/reservations" type OrderEditItemProps = { item: HttpTypes.AdminOrderLineItem currencyCode: string locationId?: string onItemRemove: (itemId: string) => void + itemReservedQuantitiesMap: Map form: UseFormReturn> } @@ -23,6 +25,7 @@ export function OrderCreateFulfillmentItem({ item, form, locationId, + itemReservedQuantitiesMap, }: OrderEditItemProps) { const { t } = useTranslation() @@ -49,11 +52,14 @@ export function OrderCreateFulfillmentItem({ return {} } + const reservedQuantityForItem = itemReservedQuantitiesMap.get(item.id) ?? 0 + return { - availableQuantity: locationInventory.available_quantity, + availableQuantity: + locationInventory.available_quantity + reservedQuantityForItem, inStockQuantity: locationInventory.stocked_quantity, } - }, [variant, locationId]) + }, [variant, locationId, itemReservedQuantitiesMap]) const minValue = 0 const maxValue = Math.min( From 44b7cee9d49ae3eb5334c42358e7f6f070212a49 Mon Sep 17 00:00:00 2001 From: fPolic Date: Wed, 23 Oct 2024 15:30:30 +0200 Subject: [PATCH 4/4] fix: refresh reservations when order fulfillment is created --- packages/admin/dashboard/src/hooks/api/orders.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/admin/dashboard/src/hooks/api/orders.tsx b/packages/admin/dashboard/src/hooks/api/orders.tsx index 832fd8a1e6fbe..f694a00e84b04 100644 --- a/packages/admin/dashboard/src/hooks/api/orders.tsx +++ b/packages/admin/dashboard/src/hooks/api/orders.tsx @@ -1,5 +1,5 @@ import { FetchError } from "@medusajs/js-sdk" -import { AdminOrderItemsFilters, HttpTypes } from "@medusajs/types" +import { HttpTypes } from "@medusajs/types" import { QueryKey, useMutation, @@ -10,6 +10,8 @@ import { import { sdk } from "../../lib/client" import { queryClient } from "../../lib/query-client" import { queryKeysFactory, TQueryKey } from "../../lib/query-key-factory" +import { inventoryItemsQueryKeys } from "./inventory" +import { reservationItemsQueryKeys } from "./reservations" const ORDERS_QUERY_KEY = "orders" as const const _orderKeys = queryKeysFactory(ORDERS_QUERY_KEY) as TQueryKey<"orders"> & { @@ -156,6 +158,14 @@ export const useCreateOrderFulfillment = ( queryKey: ordersQueryKeys.preview(orderId), }) + queryClient.invalidateQueries({ + queryKey: reservationItemsQueryKeys.lists(), + }) + + queryClient.invalidateQueries({ + queryKey: inventoryItemsQueryKeys.details(), + }) + options?.onSuccess?.(data, variables, context) }, ...options,