Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
wants to merge 6 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions integration-tests/http/__tests__/fixtures/order.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
139 changes: 136 additions & 3 deletions integration-tests/http/__tests__/order/admin/order.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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`,
{
Expand All @@ -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",
Expand Down Expand Up @@ -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,
})
Expand All @@ -157,6 +213,83 @@ 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 only create fulfillments grouped by shipping requirement", async () => {
const {
response: { data },
Expand Down
12 changes: 11 additions & 1 deletion packages/admin/dashboard/src/hooks/api/orders.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { FetchError } from "@medusajs/js-sdk"
import { AdminOrderItemsFilters, HttpTypes } from "@medusajs/types"
import { HttpTypes } from "@medusajs/types"
import {
QueryKey,
useMutation,
Expand All @@ -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"> & {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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) =>
Expand Down Expand Up @@ -246,6 +255,9 @@ export function OrderCreateFulfillmentForm({
form={form}
item={item}
locationId={selectedLocationId}
itemReservedQuantitiesMap={
itemReservedQuantitiesMap
}
/>
)
})}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,22 @@ 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>>
}

export function OrderCreateFulfillmentItem({
item,
form,
locationId,
itemReservedQuantitiesMap,
}: OrderEditItemProps) {
const { t } = useTranslation()

Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}
Expand Down
Loading