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:
Philip Korsholm
2023-03-14 10:35:59 +01:00
committed by GitHub
parent 271844aedb
commit 55a1f232a3
7 changed files with 256 additions and 39 deletions

View File

@@ -0,0 +1,6 @@
---
"@medusajs/admin-ui": patch
"@medusajs/medusa": patch
---
feat(medusa,admin-ui): support location_id in

View File

@@ -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()

View File

@@ -23,7 +23,7 @@ export type SelectOption<T> = {
type MultiSelectProps = InputHeaderProps & {
// component props
label: string
label?: string
required?: boolean
name?: string
className?: string

View File

@@ -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}

View File

@@ -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}

View File

@@ -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 {

View File

@@ -17,6 +17,7 @@ export type CreateReturnInput = {
no_notification?: boolean
metadata?: Record<string, unknown>
refund_amount?: number
location_id?: string
}
export type UpdateReturnInput = {