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:
@@ -22,7 +22,7 @@ export async function createOrderSeeder({
|
|||||||
container: MedusaContainer
|
container: MedusaContainer
|
||||||
productOverride?: AdminProduct
|
productOverride?: AdminProduct
|
||||||
stockChannelOverride?: AdminStockLocation
|
stockChannelOverride?: AdminStockLocation
|
||||||
additionalProducts?: AdminProduct[]
|
additionalProducts?: { variant_id: string; quantity: number }[]
|
||||||
inventoryItemOverride?: AdminInventoryItem
|
inventoryItemOverride?: AdminInventoryItem
|
||||||
}) {
|
}) {
|
||||||
const publishableKey = await generatePublishableKey(container)
|
const publishableKey = await generatePublishableKey(container)
|
||||||
@@ -195,10 +195,7 @@ export async function createOrderSeeder({
|
|||||||
sales_channel_id: salesChannel.id,
|
sales_channel_id: salesChannel.id,
|
||||||
items: [
|
items: [
|
||||||
{ quantity: 1, variant_id: product.variants[0].id },
|
{ quantity: 1, variant_id: product.variants[0].id },
|
||||||
...(additionalProducts || []).map((p) => ({
|
...(additionalProducts || []),
|
||||||
quantity: 1,
|
|
||||||
variant_id: p.variants?.[0]?.id,
|
|
||||||
})),
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
storeHeaders
|
storeHeaders
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import {
|
|||||||
import { setupTaxStructure } from "../../../../modules/__tests__/fixtures"
|
import { setupTaxStructure } from "../../../../modules/__tests__/fixtures"
|
||||||
import { createOrderSeeder } from "../../fixtures/order"
|
import { createOrderSeeder } from "../../fixtures/order"
|
||||||
|
|
||||||
jest.setTimeout(30000)
|
jest.setTimeout(300000)
|
||||||
|
|
||||||
medusaIntegrationTestRunner({
|
medusaIntegrationTestRunner({
|
||||||
testSuite: ({ dbConnection, getContainer, api }) => {
|
testSuite: ({ dbConnection, getContainer, api }) => {
|
||||||
let order, seeder
|
let order, seeder, inventoryItemOverride3, productOverride3
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const container = getContainer()
|
const container = getContainer()
|
||||||
@@ -82,6 +82,14 @@ medusaIntegrationTestRunner({
|
|||||||
)
|
)
|
||||||
).data.inventory_item
|
).data.inventory_item
|
||||||
|
|
||||||
|
inventoryItemOverride3 = (
|
||||||
|
await api.post(
|
||||||
|
`/admin/inventory-items`,
|
||||||
|
{ sku: "test-variant-3", requires_shipping: false },
|
||||||
|
adminHeaders
|
||||||
|
)
|
||||||
|
).data.inventory_item
|
||||||
|
|
||||||
await api.post(
|
await api.post(
|
||||||
`/admin/inventory-items/${inventoryItemOverride2.id}/location-levels`,
|
`/admin/inventory-items/${inventoryItemOverride2.id}/location-levels`,
|
||||||
{
|
{
|
||||||
@@ -91,6 +99,15 @@ medusaIntegrationTestRunner({
|
|||||||
adminHeaders
|
adminHeaders
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await api.post(
|
||||||
|
`/admin/inventory-items/${inventoryItemOverride3.id}/location-levels`,
|
||||||
|
{
|
||||||
|
location_id: stockChannelOverride.id,
|
||||||
|
stocked_quantity: 10,
|
||||||
|
},
|
||||||
|
adminHeaders
|
||||||
|
)
|
||||||
|
|
||||||
const productOverride2 = (
|
const productOverride2 = (
|
||||||
await api.post(
|
await api.post(
|
||||||
"/admin/products",
|
"/admin/products",
|
||||||
@@ -127,11 +144,50 @@ medusaIntegrationTestRunner({
|
|||||||
)
|
)
|
||||||
).data.product
|
).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({
|
seeder = await createOrderSeeder({
|
||||||
api,
|
api,
|
||||||
container: getContainer(),
|
container: getContainer(),
|
||||||
productOverride,
|
productOverride,
|
||||||
additionalProducts: [productOverride2],
|
additionalProducts: [
|
||||||
|
{ variant_id: productOverride2.variants[0].id, quantity: 1 },
|
||||||
|
{ variant_id: productOverride3.variants[0].id, quantity: 3 },
|
||||||
|
],
|
||||||
stockChannelOverride,
|
stockChannelOverride,
|
||||||
inventoryItemOverride,
|
inventoryItemOverride,
|
||||||
})
|
})
|
||||||
@@ -157,6 +213,105 @@ medusaIntegrationTestRunner({
|
|||||||
expect(response2.orders[0].email).toEqual(userEmail)
|
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 () => {
|
it("should only create fulfillments grouped by shipping requirement", async () => {
|
||||||
const {
|
const {
|
||||||
response: { data },
|
response: { data },
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FetchError } from "@medusajs/js-sdk"
|
import { FetchError } from "@medusajs/js-sdk"
|
||||||
import { AdminOrderItemsFilters, HttpTypes } from "@medusajs/types"
|
import { HttpTypes } from "@medusajs/types"
|
||||||
import {
|
import {
|
||||||
QueryKey,
|
QueryKey,
|
||||||
useMutation,
|
useMutation,
|
||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
import { sdk } from "../../lib/client"
|
import { sdk } from "../../lib/client"
|
||||||
import { queryClient } from "../../lib/query-client"
|
import { queryClient } from "../../lib/query-client"
|
||||||
import { queryKeysFactory, TQueryKey } from "../../lib/query-key-factory"
|
import { queryKeysFactory, TQueryKey } from "../../lib/query-key-factory"
|
||||||
|
import { inventoryItemsQueryKeys } from "./inventory"
|
||||||
|
import { reservationItemsQueryKeys } from "./reservations"
|
||||||
|
|
||||||
const ORDERS_QUERY_KEY = "orders" as const
|
const ORDERS_QUERY_KEY = "orders" as const
|
||||||
const _orderKeys = queryKeysFactory(ORDERS_QUERY_KEY) as TQueryKey<"orders"> & {
|
const _orderKeys = queryKeysFactory(ORDERS_QUERY_KEY) as TQueryKey<"orders"> & {
|
||||||
@@ -156,6 +158,14 @@ export const useCreateOrderFulfillment = (
|
|||||||
queryKey: ordersQueryKeys.preview(orderId),
|
queryKey: ordersQueryKeys.preview(orderId),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: reservationItemsQueryKeys.lists(),
|
||||||
|
})
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: inventoryItemsQueryKeys.details(),
|
||||||
|
})
|
||||||
|
|
||||||
options?.onSuccess?.(data, variables, context)
|
options?.onSuccess?.(data, variables, context)
|
||||||
},
|
},
|
||||||
...options,
|
...options,
|
||||||
|
|||||||
+15
-3
@@ -1,5 +1,5 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import * as zod from "zod"
|
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 { useForm, useWatch } from "react-hook-form"
|
||||||
|
|
||||||
import { OrderLineItemDTO } from "@medusajs/types"
|
import { OrderLineItemDTO } from "@medusajs/types"
|
||||||
import { useSearchParams } from "react-router-dom"
|
|
||||||
import { Form } from "../../../../../components/common/form"
|
import { Form } from "../../../../../components/common/form"
|
||||||
import {
|
import {
|
||||||
RouteFocusModal,
|
RouteFocusModal,
|
||||||
@@ -20,6 +19,7 @@ import { useStockLocations } from "../../../../../hooks/api/stock-locations"
|
|||||||
import { getFulfillableQuantity } from "../../../../../lib/order-item"
|
import { getFulfillableQuantity } from "../../../../../lib/order-item"
|
||||||
import { CreateFulfillmentSchema } from "./constants"
|
import { CreateFulfillmentSchema } from "./constants"
|
||||||
import { OrderCreateFulfillmentItem } from "./order-create-fulfillment-item"
|
import { OrderCreateFulfillmentItem } from "./order-create-fulfillment-item"
|
||||||
|
import { useReservationItems } from "../../../../../hooks/api"
|
||||||
|
|
||||||
type OrderCreateFulfillmentFormProps = {
|
type OrderCreateFulfillmentFormProps = {
|
||||||
order: AdminOrder
|
order: AdminOrder
|
||||||
@@ -32,11 +32,20 @@ export function OrderCreateFulfillmentForm({
|
|||||||
}: OrderCreateFulfillmentFormProps) {
|
}: OrderCreateFulfillmentFormProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { handleSuccess } = useRouteModal()
|
const { handleSuccess } = useRouteModal()
|
||||||
const [searchParams] = useSearchParams()
|
|
||||||
|
|
||||||
const { mutateAsync: createOrderFulfillment, isPending: isMutating } =
|
const { mutateAsync: createOrderFulfillment, isPending: isMutating } =
|
||||||
useCreateOrderFulfillment(order.id)
|
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(() =>
|
const [fulfillableItems, setFulfillableItems] = useState(() =>
|
||||||
(order.items || []).filter(
|
(order.items || []).filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
@@ -246,6 +255,9 @@ export function OrderCreateFulfillmentForm({
|
|||||||
form={form}
|
form={form}
|
||||||
item={item}
|
item={item}
|
||||||
locationId={selectedLocationId}
|
locationId={selectedLocationId}
|
||||||
|
itemReservedQuantitiesMap={
|
||||||
|
itemReservedQuantitiesMap
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
+8
-2
@@ -10,12 +10,14 @@ import { Thumbnail } from "../../../../../components/common/thumbnail/index"
|
|||||||
import { useProductVariant } from "../../../../../hooks/api/products"
|
import { useProductVariant } from "../../../../../hooks/api/products"
|
||||||
import { getFulfillableQuantity } from "../../../../../lib/order-item"
|
import { getFulfillableQuantity } from "../../../../../lib/order-item"
|
||||||
import { CreateFulfillmentSchema } from "./constants"
|
import { CreateFulfillmentSchema } from "./constants"
|
||||||
|
import { useReservationItems } from "../../../../../hooks/api/reservations"
|
||||||
|
|
||||||
type OrderEditItemProps = {
|
type OrderEditItemProps = {
|
||||||
item: HttpTypes.AdminOrderLineItem
|
item: HttpTypes.AdminOrderLineItem
|
||||||
currencyCode: string
|
currencyCode: string
|
||||||
locationId?: string
|
locationId?: string
|
||||||
onItemRemove: (itemId: string) => void
|
onItemRemove: (itemId: string) => void
|
||||||
|
itemReservedQuantitiesMap: Map<string, number>
|
||||||
form: UseFormReturn<zod.infer<typeof CreateFulfillmentSchema>>
|
form: UseFormReturn<zod.infer<typeof CreateFulfillmentSchema>>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@ export function OrderCreateFulfillmentItem({
|
|||||||
item,
|
item,
|
||||||
form,
|
form,
|
||||||
locationId,
|
locationId,
|
||||||
|
itemReservedQuantitiesMap,
|
||||||
}: OrderEditItemProps) {
|
}: OrderEditItemProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
@@ -49,11 +52,14 @@ export function OrderCreateFulfillmentItem({
|
|||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reservedQuantityForItem = itemReservedQuantitiesMap.get(item.id) ?? 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
availableQuantity: locationInventory.available_quantity,
|
availableQuantity:
|
||||||
|
locationInventory.available_quantity + reservedQuantityForItem,
|
||||||
inStockQuantity: locationInventory.stocked_quantity,
|
inStockQuantity: locationInventory.stocked_quantity,
|
||||||
}
|
}
|
||||||
}, [variant, locationId])
|
}, [variant, locationId, itemReservedQuantitiesMap])
|
||||||
|
|
||||||
const minValue = 0
|
const minValue = 0
|
||||||
const maxValue = Math.min(
|
const maxValue = Math.min(
|
||||||
|
|||||||
@@ -202,20 +202,27 @@ function prepareInventoryUpdate({
|
|||||||
|
|
||||||
const inputQuantity = inputItemsMap[item.id]?.quantity ?? item.quantity
|
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({
|
inventoryAdjustment.push({
|
||||||
inventory_item_id: reservation.inventory_item_id,
|
inventory_item_id: reservation.inventory_item_id,
|
||||||
location_id: input.location_id ?? reservation.location_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)
|
toDelete.push(reservation.id)
|
||||||
} else {
|
} else {
|
||||||
toUpdate.push({
|
toUpdate.push({
|
||||||
id: reservation.id,
|
id: reservation.id,
|
||||||
quantity: quantity,
|
quantity: remainingReservationQuantity,
|
||||||
location_id: input.location_id ?? reservation.location_id,
|
location_id: input.location_id ?? reservation.location_id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user