fix(core-flows, dashboard): adjust stock levels when doing partial fulfilments (#9736)

* fix: correctly update stock location when partial fulfillemnt is created

* fix: update test

* fix: count reserved quantity of the item as available quantity for fulfillment

* fix: refresh reservations when order fulfillment is created

* feat: add check for reservation quantity

* feat: add a test case
This commit is contained in:
Frane Polić
2024-11-04 09:04:58 +01:00
committed by GitHub
parent a0034a0fef
commit 1889180319
6 changed files with 205 additions and 18 deletions
@@ -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
@@ -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,105 @@ 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`,
{
location_id: seeder.stockLocation.id,
items: [{ id: orderItemId, quantity: 1 }],
},
adminHeaders
)
expect(fulfillableOrder.fulfillments).toHaveLength(3)
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)
})
it("should throw if trying to fulfillment more items than it is reserved", async () => {
const orderItemId = order.items.find(
(i) => i.variant_id === productOverride3.variants[0].id
).id
const res = await api
.post(
`/admin/orders/${order.id}/fulfillments`,
{
location_id: seeder.stockLocation.id,
items: [{ id: orderItemId, quantity: 5 }],
},
adminHeaders
)
.catch((e) => e)
expect(res.response.status).toBe(400)
expect(res.response.data.message).toBe(
`Quantity to fulfill exceeds the reserved quantity for the item: ${orderItemId}`
)
})
it("should only create fulfillments grouped by shipping requirement", async () => {
const {
response: { data },
@@ -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,
@@ -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
}
/>
)
})}
@@ -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<string, number>
form: UseFormReturn<zod.infer<typeof CreateFulfillmentSchema>>
}
@@ -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(
@@ -202,20 +202,27 @@ function prepareInventoryUpdate({
const inputQuantity = inputItemsMap[item.id]?.quantity ?? item.quantity
const quantity = reservation.quantity - inputQuantity
if (MathBN.gt(inputQuantity, reservation.quantity)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Quantity to fulfill exceeds the reserved quantity for the item: ${item.id}`
)
}
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,
})
}