fix(core-flows, dashboard, types): improve allocation flows on Admin (#12572)

**What**
- handle single inventory items with `required_quantity > 1` correctly in the allocate UI flow
- display correct availability amount in the create fulullment form for inventory kit items
- fix check in the create fulfillment flow that would allow negative reservation because `required_quantity` wasn't included in the check
- show the most recent reservations first in the reservations table
- display an error in the allocation form if a reservation is not created for some inventory items 
- display inventory kit in the order summary if the product has multiple same inventory items

---

CLOSES SUP-1655
This commit is contained in:
Frane Polić authored and GitHub committed 2025-08-19 18:48:24 +00:00
1 parent 67d3660abf
commit 2f594291ad
15 files changed
+295 -55

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
"@medusajs/dashboard": patch
"@medusajs/core-flows": patch
"@medusajs/types": patch
---
fix(core-flows, dashboard, types): improve allocation flows in Admin
@@ -13,7 +13,12 @@ jest.setTimeout(300000)
medusaIntegrationTestRunner({ medusaIntegrationTestRunner({
testSuite: ({ dbConnection, getContainer, api }) => { testSuite: ({ dbConnection, getContainer, api }) => {
let order, seeder, inventoryItemOverride3, productOverride3, shippingProfile let order,
seeder,
inventoryItemOverride3,
productOverride3,
shippingProfile,
productOverride4
beforeEach(async () => { beforeEach(async () => {
const container = getContainer() const container = getContainer()
@@ -836,6 +841,56 @@ medusaIntegrationTestRunner({
) )
).data.product ).data.product
const inventoryItemOverride4 = (
await api.post(
`/admin/inventory-items`,
{ sku: "test-variant-4-no-shipping", requires_shipping: false },
adminHeaders
)
).data.inventory_item
await api.post(
`/admin/inventory-items/${inventoryItemOverride4.id}/location-levels`,
{
location_id: stockChannelOverride.id,
stocked_quantity: 10,
},
adminHeaders
)
productOverride4 = (
await api.post(
"/admin/products",
{
title: `Test override 4`,
shipping_profile_id: shippingProfile.id,
options: [{ title: "size", values: ["large"] }],
variants: [
{
title: "Test variant 4",
sku: "test-variant-4-override",
inventory_items: [
{
inventory_item_id: inventoryItemOverride4.id,
required_quantity: 3,
},
],
prices: [
{
currency_code: "usd",
amount: 100,
},
],
options: {
size: "large",
},
},
],
},
adminHeaders
)
).data.product
shippingProfileOverride = ( shippingProfileOverride = (
await api.post( await api.post(
`/admin/shipping-profiles`, `/admin/shipping-profiles`,
@@ -889,6 +944,7 @@ medusaIntegrationTestRunner({
additionalProducts: [ additionalProducts: [
{ variant_id: productOverride2.variants[0].id, quantity: 1 }, { variant_id: productOverride2.variants[0].id, quantity: 1 },
{ variant_id: productOverride3.variants[0].id, quantity: 3 }, { variant_id: productOverride3.variants[0].id, quantity: 3 },
{ variant_id: productOverride4.variants[0].id, quantity: 1 },
{ {
variant_id: variant_id:
productOverride4WithOverrideShippingProfile.variants[0].id, productOverride4WithOverrideShippingProfile.variants[0].id,
@@ -1024,6 +1080,50 @@ medusaIntegrationTestRunner({
) )
}) })
it("should throw if trying to fulfillment more items than it is reserved when item has required quantity", async () => {
const orderItemId = order.items.find(
(i) => i.variant_id === productOverride4.variants[0].id
).id
let reservation = (
await api.get(
`/admin/reservations?line_item_id=${orderItemId}`,
adminHeaders
)
).data.reservations[0]
expect(reservation.quantity).toBe(3) // one item with required quantity 3
reservation = (
await api.post(
`/admin/reservations/${reservation.id}`,
{
quantity: 2,
},
adminHeaders
)
).data.reservation
expect(reservation.quantity).toBe(2)
const res = await api
.post(
`/admin/orders/${order.id}/fulfillments`,
{
shipping_option_id: seeder.shippingOption.id,
location_id: seeder.stockLocation.id,
items: [{ id: orderItemId, quantity: 1 }], // fulfill 1 orer item which requires 3 inventor items
},
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 throw if shipping profile of the product doesn't match the shipping profile of the shipping option", async () => { it("should throw if shipping profile of the product doesn't match the shipping profile of the shipping option", async () => {
const orderItemId = order.items.find( const orderItemId = order.items.find(
(i) => (i) =>
@@ -3816,16 +3816,31 @@
"credit": { "credit": {
"type": "string" "type": "string"
}, },
"creditDescription": {
"type": "string"
},
"debit": { "debit": {
"type": "string" "type": "string"
}, },
"debitDescription": { "debitDescription": {
"type": "string" "type": "string"
},
"creditDescription": {
"type": "string"
} }
} },
"required": [
"title",
"total",
"creditOrDebit",
"createCreditLine",
"createCreditLineSuccess",
"createCreditLineError",
"createCreditLineDescription",
"operation",
"credit",
"creditDescription",
"debit",
"debitDescription"
],
"additionalProperties": false
}, },
"balanceSettlement": { "balanceSettlement": {
"type": "object", "type": "object",
@@ -3851,9 +3866,18 @@
"creditLineDescription": { "creditLineDescription": {
"type": "string" "type": "string"
} }
} },
"required": [
"paymentMethod",
"paymentMethodDescription",
"creditLine",
"creditLineDescription"
],
"additionalProperties": false
} }
} },
"required": ["title", "settlementType", "settlementTypes"],
"additionalProperties": false
}, },
"domain": { "domain": {
"type": "string" "type": "string"
@@ -4876,9 +4900,12 @@
"properties": { "properties": {
"created": { "created": {
"type": "string" "type": "string"
},
"error": {
"type": "string"
} }
}, },
"required": ["created"], "required": ["created", "error"],
"additionalProperties": false "additionalProperties": false
}, },
"error": { "error": {
@@ -5539,6 +5566,9 @@
} }
}, },
"required": [ "required": [
"giftCardsStoreCreditLines",
"creditLines",
"balanceSettlement",
"domain", "domain",
"claim", "claim",
"exchange", "exchange",
@@ -11229,6 +11259,8 @@
}, },
"required": [ "required": [
"amount", "amount",
"reference",
"reference_id",
"refundAmount", "refundAmount",
"name", "name",
"default", "default",
@@ -11315,6 +11347,7 @@
"account", "account",
"total", "total",
"paidTotal", "paidTotal",
"creditTotal",
"totalExclTax", "totalExclTax",
"subtotal", "subtotal",
"shipping", "shipping",
@@ -11497,6 +11530,7 @@
"invite", "invite",
"resetPassword", "resetPassword",
"workflowExecutions", "workflowExecutions",
"shippingOptionTypes",
"productTypes", "productTypes",
"productTags", "productTags",
"notifications", "notifications",
@@ -1305,7 +1305,8 @@
"consistsOf": "Consists of {{num}}x inventory items", "consistsOf": "Consists of {{num}}x inventory items",
"requires": "Requires {{num}} per variant", "requires": "Requires {{num}} per variant",
"toast": { "toast": {
"created": "Items successfully allocated" "created": "Items successfully allocated",
"error": "Failed to allocate following items: {{items}}"
}, },
"error": { "error": {
"quantityNotAllocated": "There are unallocated items." "quantityNotAllocated": "There are unallocated items."
@@ -31,7 +31,7 @@ const EditSalesChannelsSchema = zod.object({
sales_channels: zod.array(zod.string()).optional(), sales_channels: zod.array(zod.string()).optional(),
}) })
const PAGE_SIZE = 50 const PAGE_SIZE = 20
const PREFIX = "sc" const PREFIX = "sc"
export const LocationEditSalesChannelsForm = ({ export const LocationEditSalesChannelsForm = ({
@@ -19,6 +19,7 @@ import { useStockLocations } from "../../../../../hooks/api/stock-locations"
import { queryClient } from "../../../../../lib/query-client" import { queryClient } from "../../../../../lib/query-client"
import { AllocateItemsSchema } from "./constants" import { AllocateItemsSchema } from "./constants"
import { OrderAllocateItemsItem } from "./order-allocate-items-item" import { OrderAllocateItemsItem } from "./order-allocate-items-item"
import { checkInventoryKit } from "./utils"
type OrderAllocateItemsFormProps = { type OrderAllocateItemsFormProps = {
order: AdminOrder order: AdminOrder
@@ -84,16 +85,18 @@ export function OrderAllocateItemsForm({ order }: OrderAllocateItemsFormProps) {
const promises = payload.map(([itemId, inventoryId, quantity]) => const promises = payload.map(([itemId, inventoryId, quantity]) =>
allocateItems({ allocateItems({
location_id: data.location_id, location_id: data.location_id,
inventory_item_id: inventoryId, inventory_item_id: inventoryId as string,
line_item_id: itemId, line_item_id: itemId as string,
quantity, quantity: Number(quantity),
}) })
.then(() => ({ success: true, inventory_item_id: inventoryId }))
.catch(() => ({ success: false, inventory_item_id: inventoryId }))
) )
/** /**
* TODO: we should have bulk endpoint for this so this is executed in a workflow and can be reverted * TODO: we should have bulk endpoint for this so this is executed in a workflow and can be reverted
*/ */
await Promise.all(promises) const results = await Promise.all(promises)
// invalidate order details so we get new item.variant.inventory items // invalidate order details so we get new item.variant.inventory items
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
@@ -102,10 +105,19 @@ export function OrderAllocateItemsForm({ order }: OrderAllocateItemsFormProps) {
handleSuccess(`/orders/${order.id}`) handleSuccess(`/orders/${order.id}`)
toast.success(t("general.success"), { if (results.some((r) => !r.success)) {
description: t("orders.allocateItems.toast.created"), const failedItems = results
dismissLabel: t("actions.close"), .filter((r) => !r.success)
}) .map((r) => r.inventory_item_id)
.join(", ")
toast.error(t("general.error"), {
description: t("orders.allocateItems.toast.error", {
items: failedItems,
}),
dismissLabel: t("actions.close"),
})
}
} catch (e) { } catch (e) {
toast.error(t("general.error"), { toast.error(t("general.error"), {
description: e.message, description: e.message,
@@ -313,7 +325,7 @@ function defaultAllocations(items: OrderLineItemDTO) {
const ret = {} const ret = {}
items.forEach((item) => { items.forEach((item) => {
const hasInventoryKit = item.variant?.inventory_items.length > 1 const hasInventoryKit = checkInventoryKit(item)
ret[ ret[
hasInventoryKit hasInventoryKit
@@ -14,6 +14,7 @@ import { Thumbnail } from "../../../../../components/common/thumbnail"
import { getFulfillableQuantity } from "../../../../../lib/order-item" import { getFulfillableQuantity } from "../../../../../lib/order-item"
import { Form } from "../../../../../components/common/form" import { Form } from "../../../../../components/common/form"
import { AllocateItemsSchema } from "./constants" import { AllocateItemsSchema } from "./constants"
import { checkInventoryKit } from "./utils"
type OrderEditItemProps = { type OrderEditItemProps = {
item: OrderLineItemDTO item: OrderLineItemDTO
@@ -46,8 +47,7 @@ export function OrderAllocateItemsItem({
name: "quantity", name: "quantity",
}) })
const hasInventoryKit = const hasInventoryKit = checkInventoryKit(item)
!!variant?.inventory_items.length && variant?.inventory_items.length > 1
const { availableQuantity, inStockQuantity } = useMemo(() => { const { availableQuantity, inStockQuantity } = useMemo(() => {
if (!variant || !locationId) { if (!variant || !locationId) {
@@ -0,0 +1,28 @@
import {
AdminProductVariant,
AdminProductVariantInventoryItemLink,
OrderLineItemDTO,
} from "@medusajs/types"
/**
* Check if the line item has inventory kit.
*/
export function checkInventoryKit(
item: OrderLineItemDTO & {
variant?: AdminProductVariant & {
inventory_items: AdminProductVariantInventoryItemLink[]
}
}
) {
const variant = item.variant
if (!variant) {
return false
}
return (
(!!variant.inventory_items.length && variant.inventory_items.length > 1) ||
(variant.inventory_items.length === 1 &&
variant.inventory_items[0].required_quantity! > 1)
)
}
@@ -57,12 +57,6 @@ export function OrderCreateFulfillmentForm({
})), })),
}) })
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) =>
@@ -364,9 +358,7 @@ export function OrderCreateFulfillmentForm({
disabled={ disabled={
requiresShipping && !isShippingProfileMatching requiresShipping && !isShippingProfileMatching
} }
itemReservedQuantitiesMap={ reservations={reservations}
itemReservedQuantitiesMap
}
/> />
) )
})} })}
@@ -17,7 +17,7 @@ type OrderEditItemProps = {
currencyCode: string currencyCode: string
locationId?: string locationId?: string
onItemRemove: (itemId: string) => void onItemRemove: (itemId: string) => void
itemReservedQuantitiesMap: Map<string, number> reservations: HttpTypes.AdminReservation[]
form: UseFormReturn<zod.infer<typeof CreateFulfillmentSchema>> form: UseFormReturn<zod.infer<typeof CreateFulfillmentSchema>>
disabled: boolean disabled: boolean
} }
@@ -26,7 +26,7 @@ export function OrderCreateFulfillmentItem({
item, item,
form, form,
locationId, locationId,
itemReservedQuantitiesMap, reservations,
disabled, disabled,
}: OrderEditItemProps) { }: OrderEditItemProps) {
const { t } = useTranslation() const { t } = useTranslation()
@@ -35,7 +35,7 @@ export function OrderCreateFulfillmentItem({
item.product_id, item.product_id,
item.variant_id, item.variant_id,
{ {
fields: "*inventory,*inventory.location_levels", fields: "*inventory,*inventory.location_levels,*inventory_items",
}, },
{ {
enabled: !!item.variant, enabled: !!item.variant,
@@ -43,28 +43,83 @@ export function OrderCreateFulfillmentItem({
) )
const { availableQuantity, inStockQuantity } = useMemo(() => { const { availableQuantity, inStockQuantity } = useMemo(() => {
if (!variant || !locationId) { if (
!variant?.inventory_items?.length ||
!variant?.inventory?.length ||
!locationId
) {
return {} return {}
} }
const { inventory } = variant const { inventory, inventory_items } = variant
const locationInventory = inventory[0]?.location_levels?.find( const locationHasEveryInventoryItem = inventory.every((i) =>
(inv) => inv.location_id === locationId i.location_levels?.find((inv) => inv.location_id === locationId)
) )
if (!locationInventory) { if (!locationHasEveryInventoryItem) {
return {} return {}
} }
const reservedQuantityForItem = itemReservedQuantitiesMap.get(item.id) ?? 0 const inventoryItemRequiredQuantityMap = new Map(
inventory_items.map((i) => [i.inventory_item_id, i.required_quantity])
)
// since we don't allow split fulifllments only one reservation from inventory kit is enough to calculate avalabel product quantity
const reservation = reservations?.find((r) => r.line_item_id === item.id)
const iitemRequiredQuantity = inventory_items.find(
(i) => i.inventory_item_id === reservation?.inventory_item_id
)?.required_quantity
const reservedQuantityForItem = !reservation
? 0
: reservation?.quantity / (iitemRequiredQuantity || 1)
const locationInventoryLevels = inventory.map((i) => {
const level = i.location_levels?.find(
(inv) => inv.location_id === locationId
)
const requiredQuantity = inventoryItemRequiredQuantityMap.get(i.id)
if (!level || !requiredQuantity) {
return {
availableQuantity: Number.MAX_SAFE_INTEGER,
stockedQuantity: Number.MAX_SAFE_INTEGER,
}
}
const availableQuantity = level.available_quantity / requiredQuantity
const stockedQuantity = level.stocked_quantity / requiredQuantity
return {
availableQuantity,
stockedQuantity,
}
})
const maxAvailableQuantity = Math.min(
...locationInventoryLevels.map((i) => i.availableQuantity)
)
const maxStockedQuantity = Math.min(
...locationInventoryLevels.map((i) => i.stockedQuantity)
)
if (
maxAvailableQuantity === Number.MAX_SAFE_INTEGER ||
maxStockedQuantity === Number.MAX_SAFE_INTEGER
) {
return {}
}
return { return {
availableQuantity: availableQuantity: Math.floor(
locationInventory.available_quantity + reservedQuantityForItem, maxAvailableQuantity + reservedQuantityForItem
inStockQuantity: locationInventory.stocked_quantity, ),
inStockQuantity: Math.floor(maxStockedQuantity),
} }
}, [variant, locationId, itemReservedQuantitiesMap]) }, [variant, locationId, reservations])
const minValue = 0 const minValue = 0
const maxValue = Math.min( const maxValue = Math.min(
@@ -76,7 +131,7 @@ export function OrderCreateFulfillmentItem({
<div className="bg-ui-bg-subtle shadow-elevation-card-rest my-2 rounded-xl"> <div className="bg-ui-bg-subtle shadow-elevation-card-rest my-2 rounded-xl">
<div className="flex flex-row items-center"> <div className="flex flex-row items-center">
{disabled && ( {disabled && (
<div className="inline-flex items-center ml-4"> <div className="ml-4 inline-flex items-center">
<Tooltip <Tooltip
content={t("orders.fulfillment.disabledItemTooltip")} content={t("orders.fulfillment.disabledItemTooltip")}
side="top" side="top"
@@ -88,8 +143,8 @@ export function OrderCreateFulfillmentItem({
<div <div
className={clx( className={clx(
"flex flex-col flex-1 gap-x-2 gap-y-2 border-b p-3 text-sm sm:flex-row", "flex flex-1 flex-col gap-x-2 gap-y-2 border-b p-3 text-sm sm:flex-row",
disabled && "opacity-50 pointer-events-none" disabled && "pointer-events-none opacity-50"
)} )}
> >
<div className="flex flex-1 items-center gap-x-3"> <div className="flex flex-1 items-center gap-x-3">
@@ -397,7 +397,9 @@ const Item = ({
const isInventoryManaged = item.variant?.manage_inventory const isInventoryManaged = item.variant?.manage_inventory
const hasInventoryKit = const hasInventoryKit =
isInventoryManaged && (item.variant?.inventory_items?.length || 0) > 1 isInventoryManaged &&
((item.variant?.inventory_items?.length || 0) > 1 ||
item.variant?.inventory_items?.some((i) => i.required_quantity > 1))
const hasUnfulfilledItems = item.quantity - item.detail.fulfilled_quantity > 0 const hasUnfulfilledItems = item.quantity - item.detail.fulfilled_quantity > 0
return ( return (
@@ -13,7 +13,7 @@ export const useReservationTableQuery = ({
prefix prefix
) )
const { location_id, created_at, updated_at, quantity, offset, ...rest } = raw const { location_id, created_at, updated_at, order, offset, ...rest } = raw
const searchParams: HttpTypes.AdminGetReservationsParams = { const searchParams: HttpTypes.AdminGetReservationsParams = {
limit: pageSize, limit: pageSize,
@@ -21,6 +21,7 @@ export const useReservationTableQuery = ({
location_id: location_id, location_id: location_id,
created_at: created_at ? JSON.parse(created_at) : undefined, created_at: created_at ? JSON.parse(created_at) : undefined,
updated_at: updated_at ? JSON.parse(updated_at) : undefined, updated_at: updated_at ? JSON.parse(updated_at) : undefined,
order: order ?? "-created_at",
...rest, ...rest,
} }
@@ -306,13 +306,6 @@ function prepareInventoryUpdate({
const inputQuantity = inputItemsMap[item.id]?.quantity ?? item.quantity const inputQuantity = inputItemsMap[item.id]?.quantity ?? item.quantity
reservations.forEach((reservation) => { reservations.forEach((reservation) => {
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 iItem = orderItem?.variant?.inventory_items.find( const iItem = orderItem?.variant?.inventory_items.find(
(ii) => ii.inventory.id === reservation.inventory_item_id (ii) => ii.inventory.id === reservation.inventory_item_id
) )
@@ -327,6 +320,13 @@ function prepareInventoryUpdate({
adjustemntQuantity adjustemntQuantity
) )
if (MathBN.lt(remainingReservationQuantity, 0)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Quantity to fulfill exceeds the reserved quantity for the item: ${item.id}`
)
}
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,
@@ -36,6 +36,10 @@ export interface AdminProductVariantInventoryItemLink {
* The inventory item that is linked to the variant. * The inventory item that is linked to the variant.
*/ */
inventory?: AdminInventoryItem inventory?: AdminInventoryItem
/**
* The quantity of the inventory item that is required to fulfill the variant.
*/
required_quantity?: number
} }
export interface AdminProductVariant extends BaseProductVariant { export interface AdminProductVariant extends BaseProductVariant {
@@ -25,6 +25,10 @@ export interface AdminGetReservationsParams {
* reservations for. * reservations for.
*/ */
line_item_id?: string | string[] line_item_id?: string | string[]
/**
* Sort the reservations by the given field.
*/
order_id?: string | string[]
/** /**
* Filter by the ID(s) of the user(s) to retrieve the * Filter by the ID(s) of the user(s) to retrieve the
* reservations they created. * reservations they created.