Feat(admin-ui, medusa): request return with location (#3451)
* add location_id to request_return endpoint to support "receive_now" returns * changeset * admin request return * add locations to recieving returns * cleanup test * add check for inventory service
This commit is contained in:
6
.changeset/weak-hats-develop.md
Normal file
6
.changeset/weak-hats-develop.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@medusajs/admin-ui": patch
|
||||
"@medusajs/medusa": patch
|
||||
---
|
||||
|
||||
feat(medusa,admin-ui): support location_id in
|
||||
@@ -198,6 +198,65 @@ describe("/store/carts", () => {
|
||||
)
|
||||
})
|
||||
|
||||
it("increases stocked quantity when return is received at location", async () => {
|
||||
const api = useApi()
|
||||
|
||||
const fulfillmentRes = await api.post(
|
||||
`/admin/orders/${order.id}/fulfillment`,
|
||||
{
|
||||
items: [{ item_id: lineItemId, quantity: 1 }],
|
||||
location_id: locationId,
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
const shipmentRes = await api.post(
|
||||
`/admin/orders/${order.id}/shipment`,
|
||||
{
|
||||
fulfillment_id: fulfillmentRes.data.order.fulfillments[0].id,
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
expect(shipmentRes.status).toBe(200)
|
||||
|
||||
let inventoryItem = await api.get(
|
||||
`/admin/inventory-items/${invItemId}`,
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
expect(inventoryItem.data.inventory_item.location_levels[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
stocked_quantity: 0,
|
||||
reserved_quantity: 0,
|
||||
available_quantity: 0,
|
||||
})
|
||||
)
|
||||
|
||||
const requestReturnRes = await api.post(
|
||||
`/admin/orders/${order.id}/return`,
|
||||
{
|
||||
receive_now: true,
|
||||
location_id: locationId,
|
||||
items: [{ item_id: lineItemId, quantity: 1 }],
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
expect(requestReturnRes.status).toBe(200)
|
||||
inventoryItem = await api.get(
|
||||
`/admin/inventory-items/${invItemId}`,
|
||||
adminHeaders
|
||||
)
|
||||
expect(inventoryItem.data.inventory_item.location_levels[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
stocked_quantity: 1,
|
||||
reserved_quantity: 0,
|
||||
available_quantity: 1,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("adjusts inventory levels on successful fulfillment without reservation", async () => {
|
||||
const api = useApi()
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export type SelectOption<T> = {
|
||||
|
||||
type MultiSelectProps = InputHeaderProps & {
|
||||
// component props
|
||||
label: string
|
||||
label?: string
|
||||
required?: boolean
|
||||
name?: string
|
||||
className?: string
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { Order, Return } from "@medusajs/medusa"
|
||||
import React from "react"
|
||||
import {
|
||||
AdminPostReturnsReturnReceiveReq,
|
||||
Order,
|
||||
Return,
|
||||
StockLocationDTO,
|
||||
} from "@medusajs/medusa"
|
||||
import { useAdminOrder, useAdminReceiveReturn } from "medusa-react"
|
||||
import { useEffect, useMemo } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import Button from "../../../../components/fundamentals/button"
|
||||
import Modal from "../../../../components/molecules/modal"
|
||||
import useNotification from "../../../../hooks/use-notification"
|
||||
import { useFeatureFlag } from "../../../../providers/feature-flag-provider"
|
||||
import { getErrorMessage } from "../../../../utils/error-messages"
|
||||
import { nestedForm } from "../../../../utils/nested-form"
|
||||
import { ItemsToReceiveFormType } from "../../components/items-to-receive-form"
|
||||
@@ -13,6 +20,9 @@ import { RefundAmountFormType } from "../../components/refund-amount-form"
|
||||
import { ReceiveReturnSummary } from "../../components/rma-summaries/receive-return-summary"
|
||||
import { getDefaultReceiveReturnValues } from "../utils/get-default-values"
|
||||
import useOrdersExpandParam from "../utils/use-admin-expand-paramter"
|
||||
import { useAdminStockLocations } from "medusa-react"
|
||||
import Select from "../../../../components/molecules/select/next-select/select"
|
||||
import Spinner from "../../../../components/atoms/spinner"
|
||||
|
||||
type Props = {
|
||||
order: Order
|
||||
@@ -26,12 +36,53 @@ export type ReceiveReturnFormType = {
|
||||
}
|
||||
|
||||
export const ReceiveReturnMenu = ({ order, returnRequest, onClose }: Props) => {
|
||||
const { isFeatureEnabled } = useFeatureFlag()
|
||||
const isLocationFulfillmentEnabled =
|
||||
isFeatureEnabled("inventoryService") &&
|
||||
isFeatureEnabled("stockLocationService")
|
||||
|
||||
const { mutate, isLoading } = useAdminReceiveReturn(returnRequest.id)
|
||||
const { orderRelations } = useOrdersExpandParam()
|
||||
const { refetch } = useAdminOrder(order.id, {
|
||||
expand: orderRelations,
|
||||
})
|
||||
|
||||
const {
|
||||
stock_locations,
|
||||
refetch: refetchLocations,
|
||||
isLoading: isLoadingLocations,
|
||||
} = useAdminStockLocations(
|
||||
{},
|
||||
{
|
||||
enabled: isLocationFulfillmentEnabled,
|
||||
}
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isLocationFulfillmentEnabled) {
|
||||
refetchLocations()
|
||||
}
|
||||
}, [isLocationFulfillmentEnabled, refetchLocations])
|
||||
|
||||
const [selectedLocation, setSelectedLocation] = React.useState<{
|
||||
value: string
|
||||
label: string
|
||||
} | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isLocationFulfillmentEnabled && stock_locations?.length) {
|
||||
const location = stock_locations.find(
|
||||
(sl: StockLocationDTO) => sl.id === returnRequest.location_id
|
||||
)
|
||||
if (location) {
|
||||
setSelectedLocation({
|
||||
value: location.id,
|
||||
label: location.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [isLocationFulfillmentEnabled, stock_locations, returnRequest.location_id])
|
||||
|
||||
/**
|
||||
* If the return was refunded as part of a refund claim, we do not allow the user to
|
||||
* specify a refund amount, or want to display a summary.
|
||||
@@ -104,36 +155,39 @@ export const ReceiveReturnMenu = ({ order, returnRequest, onClose }: Props) => {
|
||||
refundAmount = 0
|
||||
}
|
||||
|
||||
mutate(
|
||||
{
|
||||
items: data.receive_items.items.map((i) => ({
|
||||
item_id: i.item_id,
|
||||
quantity: i.quantity,
|
||||
})),
|
||||
refund: refundAmount,
|
||||
const toCreate: AdminPostReturnsReturnReceiveReq = {
|
||||
items: data.receive_items.items.map((i) => ({
|
||||
item_id: i.item_id,
|
||||
quantity: i.quantity,
|
||||
})),
|
||||
refund: refundAmount,
|
||||
}
|
||||
|
||||
if (selectedLocation && isLocationFulfillmentEnabled) {
|
||||
toCreate.location_id = selectedLocation.value
|
||||
}
|
||||
|
||||
mutate(toCreate, {
|
||||
onSuccess: () => {
|
||||
notification(
|
||||
"Successfully received return",
|
||||
`Received return for order #${order.display_id}`,
|
||||
"success"
|
||||
)
|
||||
|
||||
// We need to refetch the order to get the updated state
|
||||
refetch()
|
||||
|
||||
onClose()
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
notification(
|
||||
"Successfully received return",
|
||||
`Received return for order #${order.display_id}`,
|
||||
"success"
|
||||
)
|
||||
|
||||
// We need to refetch the order to get the updated state
|
||||
refetch()
|
||||
|
||||
onClose()
|
||||
},
|
||||
onError: (error) => {
|
||||
notification(
|
||||
"Failed to receive return",
|
||||
getErrorMessage(error),
|
||||
"error"
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
onError: (error) => {
|
||||
notification(
|
||||
"Failed to receive return",
|
||||
getErrorMessage(error),
|
||||
"error"
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -149,6 +203,33 @@ export const ReceiveReturnMenu = ({ order, returnRequest, onClose }: Props) => {
|
||||
order={order}
|
||||
form={nestedForm(form, "receive_items")}
|
||||
/>
|
||||
|
||||
{isLocationFulfillmentEnabled && (
|
||||
<div className="mb-8">
|
||||
<h3 className="inter-base-semibold ">Location</h3>
|
||||
<p className="inter-base-regular text-grey-50">
|
||||
Choose which location you want to return the items to.
|
||||
</p>
|
||||
{isLoadingLocations ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Select
|
||||
className="mt-2"
|
||||
placeholder="Select Location to Return to"
|
||||
value={selectedLocation}
|
||||
isMulti={false}
|
||||
onChange={setSelectedLocation}
|
||||
options={
|
||||
stock_locations?.map((sl: StockLocationDTO) => ({
|
||||
label: sl.name,
|
||||
value: sl.id,
|
||||
})) || []
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSwapOrRefundedClaim && (
|
||||
<ReceiveReturnSummary
|
||||
form={form}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { LineItem as RawLineItem, Order } from "@medusajs/medusa"
|
||||
import {
|
||||
AdminPostOrdersOrderReturnsReq,
|
||||
LineItem as RawLineItem,
|
||||
Order,
|
||||
StockLocationDTO,
|
||||
} from "@medusajs/medusa"
|
||||
import { useAdminStockLocations } from "medusa-react"
|
||||
import { useAdminRequestReturn, useAdminShippingOptions } from "medusa-react"
|
||||
import React, { useContext, useEffect, useState } from "react"
|
||||
import Spinner from "../../../../components/atoms/spinner"
|
||||
@@ -11,10 +17,12 @@ import LayeredModal, {
|
||||
LayeredModalContext,
|
||||
} from "../../../../components/molecules/modal/layered-modal"
|
||||
import RMAShippingPrice from "../../../../components/molecules/rma-select-shipping"
|
||||
import Select from "../../../../components/molecules/select"
|
||||
import Select from "../../../../components/molecules/select/next-select/select"
|
||||
// import Select from "../../../../components/molecules/select"
|
||||
import CurrencyInput from "../../../../components/organisms/currency-input"
|
||||
import RMASelectProductTable from "../../../../components/organisms/rma-select-product-table"
|
||||
import useNotification from "../../../../hooks/use-notification"
|
||||
import { useFeatureFlag } from "../../../../providers/feature-flag-provider"
|
||||
import { Option } from "../../../../types/shared"
|
||||
import { getErrorMessage } from "../../../../utils/error-messages"
|
||||
import { displayAmount } from "../../../../utils/prices"
|
||||
@@ -29,12 +37,20 @@ type ReturnMenuProps = {
|
||||
type LineItem = Omit<RawLineItem, "beforeInsert">
|
||||
|
||||
const ReturnMenu: React.FC<ReturnMenuProps> = ({ order, onDismiss }) => {
|
||||
const layoutmodalcontext = useContext(LayeredModalContext)
|
||||
const layeredModalContext = useContext(LayeredModalContext)
|
||||
const { isFeatureEnabled } = useFeatureFlag()
|
||||
const isLocationFulfillmentEnabled =
|
||||
isFeatureEnabled("inventoryService") &&
|
||||
isFeatureEnabled("stockLocationService")
|
||||
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [refundEdited, setRefundEdited] = useState(false)
|
||||
const [refundable, setRefundable] = useState(0)
|
||||
const [refundAmount, setRefundAmount] = useState(0)
|
||||
const [selectedLocation, setSelectedLocation] = useState<{
|
||||
value: string
|
||||
label: string
|
||||
} | null>(null)
|
||||
const [toReturn, setToReturn] = useState<
|
||||
Record<string, { quantity: number }>
|
||||
>({})
|
||||
@@ -48,6 +64,19 @@ const ReturnMenu: React.FC<ReturnMenuProps> = ({ order, onDismiss }) => {
|
||||
|
||||
const notification = useNotification()
|
||||
|
||||
const { stock_locations, refetch } = useAdminStockLocations(
|
||||
{},
|
||||
{
|
||||
enabled: isLocationFulfillmentEnabled,
|
||||
}
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isLocationFulfillmentEnabled) {
|
||||
refetch()
|
||||
}
|
||||
}, [isLocationFulfillmentEnabled, refetch])
|
||||
|
||||
const requestReturnOrder = useAdminRequestReturn(order.id)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -91,17 +120,21 @@ const ReturnMenu: React.FC<ReturnMenuProps> = ({ order, onDismiss }) => {
|
||||
const clean = removeNullish(toSet)
|
||||
return {
|
||||
item_id: key,
|
||||
...clean,
|
||||
...(clean as { quantity: number }),
|
||||
}
|
||||
})
|
||||
|
||||
const data = {
|
||||
const data: AdminPostOrdersOrderReturnsReq = {
|
||||
items,
|
||||
refund: Math.round(refundAmount),
|
||||
no_notification:
|
||||
noNotification !== order.no_notification ? noNotification : undefined,
|
||||
}
|
||||
|
||||
if (selectedLocation && isLocationFulfillmentEnabled) {
|
||||
data.location_id = selectedLocation.value
|
||||
}
|
||||
|
||||
if (shippingMethod) {
|
||||
const taxRate = shippingMethod.tax_rates.reduce((acc, curr) => {
|
||||
return acc + curr.rate / 100
|
||||
@@ -156,7 +189,7 @@ const ReturnMenu: React.FC<ReturnMenuProps> = ({ order, onDismiss }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<LayeredModal context={layoutmodalcontext} handleClose={onDismiss}>
|
||||
<LayeredModal context={layeredModalContext} handleClose={onDismiss}>
|
||||
<Modal.Body>
|
||||
<Modal.Header handleClose={onDismiss}>
|
||||
<h2 className="inter-xlarge-semibold">Request Return</h2>
|
||||
@@ -172,15 +205,39 @@ const ReturnMenu: React.FC<ReturnMenuProps> = ({ order, onDismiss }) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLocationFulfillmentEnabled && (
|
||||
<div className="mb-8">
|
||||
<h3 className="inter-base-semibold ">Location</h3>
|
||||
<p className="inter-base-regular text-grey-50">
|
||||
Choose which location you want to return the items to.
|
||||
</p>
|
||||
<Select
|
||||
className="mt-2"
|
||||
placeholder="Select Location to Return to"
|
||||
value={selectedLocation}
|
||||
isMulti={false}
|
||||
onChange={setSelectedLocation}
|
||||
options={
|
||||
stock_locations?.map((sl: StockLocationDTO) => ({
|
||||
label: sl.name,
|
||||
value: sl.id,
|
||||
})) || []
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 className="inter-base-semibold ">Shipping</h3>
|
||||
<p className="inter-base-regular text-grey-50">
|
||||
Choose which shipping method you want to use for this return.
|
||||
</p>
|
||||
{shippingLoading ? (
|
||||
<div className="flex justify-center">
|
||||
<Spinner size="medium" variant="secondary" />
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
label="Shipping Method"
|
||||
className="mt-2"
|
||||
placeholder="Add a shipping method"
|
||||
value={shippingMethod}
|
||||
|
||||
@@ -18,6 +18,8 @@ import { EntityManager } from "typeorm"
|
||||
import { Order, Return } from "../../../../models"
|
||||
import { OrdersReturnItem } from "../../../../types/orders"
|
||||
import { FindParams } from "../../../../types/common"
|
||||
import { FlagRouter } from "../../../../utils/flag-router"
|
||||
import { IInventoryService } from "../../../../interfaces"
|
||||
|
||||
/**
|
||||
* @oas [post] /admin/orders/{id}/return
|
||||
@@ -97,7 +99,7 @@ import { FindParams } from "../../../../types/common"
|
||||
export default async (req, res) => {
|
||||
const { id } = req.params
|
||||
|
||||
const value = req.validatedBody
|
||||
const value = req.validatedBody as AdminPostOrdersOrderReturnsReq
|
||||
|
||||
const idempotencyKeyService = req.scope.resolve("idempotencyKeyService")
|
||||
const manager: EntityManager = req.scope.resolve("manager")
|
||||
@@ -121,6 +123,9 @@ export default async (req, res) => {
|
||||
|
||||
try {
|
||||
const orderService: OrderService = req.scope.resolve("orderService")
|
||||
const inventoryServiceEnabled =
|
||||
!!req.scope.resolve("inventoryService") &&
|
||||
!!req.scope.resolve("stockLocationService")
|
||||
const returnService: ReturnService = req.scope.resolve("returnService")
|
||||
const eventBus: EventBusService = req.scope.resolve("eventBusService")
|
||||
|
||||
@@ -140,6 +145,9 @@ export default async (req, res) => {
|
||||
idempotency_key: idempotencyKey.idempotency_key,
|
||||
items: value.items,
|
||||
}
|
||||
if (isDefined(value.location_id) && inventoryServiceEnabled) {
|
||||
returnObj.location_id = value.location_id
|
||||
}
|
||||
|
||||
if (value.return_shipping) {
|
||||
returnObj.shipping_method = value.return_shipping
|
||||
@@ -284,6 +292,7 @@ type ReturnObj = {
|
||||
shipping_method?: ReturnShipping
|
||||
refund_amount?: number
|
||||
no_notification?: boolean
|
||||
location_id?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -363,6 +372,10 @@ export class AdminPostOrdersOrderReturnsReq {
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
refund?: number
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
location_id?: string
|
||||
}
|
||||
|
||||
class ReturnShipping {
|
||||
|
||||
@@ -17,6 +17,7 @@ export type CreateReturnInput = {
|
||||
no_notification?: boolean
|
||||
metadata?: Record<string, unknown>
|
||||
refund_amount?: number
|
||||
location_id?: string
|
||||
}
|
||||
|
||||
export type UpdateReturnInput = {
|
||||
|
||||
Reference in New Issue
Block a user