Feat(admin, medusa): add locations to claim and swap creation (#3522)

**What**
- Add location selection to claim and swap creation

Fixes CORE-1269
This commit is contained in:
Philip Korsholm
2023-03-20 14:05:37 +00:00
committed by GitHub
parent a8423b8acc
commit 55c5fba0d3
10 changed files with 224 additions and 6 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@medusajs/admin-ui": patch
"@medusajs/medusa": patch
---
feat(medusa,admin): location_ids in swap and claim creation
@@ -15,6 +15,7 @@ const {
simpleOrderFactory, simpleOrderFactory,
simpleRegionFactory, simpleRegionFactory,
simpleCartFactory, simpleCartFactory,
simpleShippingOptionFactory,
} = require("../../../factories") } = require("../../../factories")
jest.setTimeout(30000) jest.setTimeout(30000)
@@ -62,9 +63,11 @@ describe("/store/carts", () => {
let variantId let variantId
let prodVarInventoryService let prodVarInventoryService
let inventoryService let inventoryService
let lineItemService
let stockLocationService let stockLocationService
let salesChannelLocationService let salesChannelLocationService
let regionId let regionId
let orderId
beforeEach(async () => { beforeEach(async () => {
const api = useApi() const api = useApi()
@@ -72,6 +75,7 @@ describe("/store/carts", () => {
prodVarInventoryService = appContainer.resolve( prodVarInventoryService = appContainer.resolve(
"productVariantInventoryService" "productVariantInventoryService"
) )
lineItemService = appContainer.resolve("lineItemService")
inventoryService = appContainer.resolve("inventoryService") inventoryService = appContainer.resolve("inventoryService")
stockLocationService = appContainer.resolve("stockLocationService") stockLocationService = appContainer.resolve("stockLocationService")
salesChannelLocationService = appContainer.resolve( salesChannelLocationService = appContainer.resolve(
@@ -115,7 +119,7 @@ describe("/store/carts", () => {
stocked_quantity: 100, stocked_quantity: 100,
}) })
const { id: orderId } = await simpleOrderFactory(dbConnection, { const { id: order_id } = await simpleOrderFactory(dbConnection, {
sales_channel: "test-channel", sales_channel: "test-channel",
line_items: [ line_items: [
{ {
@@ -124,6 +128,8 @@ describe("/store/carts", () => {
id: "line-item-id", id: "line-item-id",
}, },
], ],
payment_status: "captured",
fulfillment_status: "fulfilled",
shipping_methods: [ shipping_methods: [
{ {
shipping_option: { shipping_option: {
@@ -133,9 +139,17 @@ describe("/store/carts", () => {
], ],
}) })
const orderRes = await api.get(`/admin/orders/${orderId}`, adminHeaders) orderId = order_id
const orderRes = await api.get(`/admin/orders/${order_id}`, adminHeaders)
order = orderRes.data.order order = orderRes.data.order
await simpleShippingOptionFactory(dbConnection, {
id: "test-return-option",
is_return: true,
region_id: regionId,
price: 0,
})
const inventoryItem = await api.get( const inventoryItem = await api.get(
`/admin/inventory-items/${invItem.id}`, `/admin/inventory-items/${invItem.id}`,
adminHeaders adminHeaders
@@ -150,6 +164,63 @@ describe("/store/carts", () => {
) )
}) })
describe("swaps", () => {
it("adjusts reservations on successful swap", async () => {
const api = useApi()
const response = await api.post(
`/admin/orders/${orderId}/swaps`,
{
return_items: [
{
item_id: "line-item-id",
quantity: 1,
},
],
return_location_id: locationId,
},
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data.order.swaps[0].return_order.location_id).toEqual(
locationId
)
})
})
describe("claims", () => {
it("adjusts reservations on successful swap", async () => {
const api = useApi()
await lineItemService.update("line-item-id", {
fulfilled_quantity: 1,
})
const response = await api.post(
`/admin/orders/${orderId}/claims`,
{
type: "refund",
claim_items: [
{
item_id: "line-item-id",
quantity: 1,
reason: "production_failure",
},
],
return_shipping: { option_id: "test-return-option", price: 0 },
return_location_id: locationId,
},
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data.order.claims[0].return_order.location_id).toEqual(
locationId
)
})
})
describe("Fulfillments", () => { describe("Fulfillments", () => {
const lineItemId = "line-item-id" const lineItemId = "line-item-id"
@@ -3,6 +3,7 @@ import faker from "faker"
import { ShippingOption, ShippingOptionPriceType, ShippingProfile, ShippingProfileType, } from "@medusajs/medusa" import { ShippingOption, ShippingOptionPriceType, ShippingProfile, ShippingProfileType, } from "@medusajs/medusa"
export type ShippingOptionFactoryData = { export type ShippingOptionFactoryData = {
id?: string
name?: string name?: string
region_id: string region_id: string
is_return?: boolean is_return?: boolean
@@ -29,7 +30,7 @@ export const simpleShippingOptionFactory = async (
}) })
const created = manager.create(ShippingOption, { const created = manager.create(ShippingOption, {
id: `simple-so-${Math.random() * 1000}`, id: data.id || `simple-so-${Math.random() * 1000}`,
name: data.name || "Test Method", name: data.name || "Test Method",
is_return: data.is_return ?? false, is_return: data.is_return ?? false,
region_id: data.region_id, region_id: data.region_id,
@@ -1,15 +1,18 @@
import { ClaimReason, Order } from "@medusajs/medusa" import { ClaimReason, Order, StockLocationDTO } from "@medusajs/medusa"
import { useAdminCreateClaim } from "medusa-react" import { useAdminStockLocations, useAdminCreateClaim } from "medusa-react"
import { useEffect } from "react" import { useEffect } from "react"
import { useForm, useWatch } from "react-hook-form" import { Controller, useForm, useWatch } from "react-hook-form"
import Spinner from "../../../../components/atoms/spinner"
import Button from "../../../../components/fundamentals/button" import Button from "../../../../components/fundamentals/button"
import Modal from "../../../../components/molecules/modal" import Modal from "../../../../components/molecules/modal"
import LayeredModal, { import LayeredModal, {
useLayeredModal, useLayeredModal,
} from "../../../../components/molecules/modal/layered-modal" } from "../../../../components/molecules/modal/layered-modal"
import Select from "../../../../components/molecules/select/next-select/select"
import { AddressPayload } from "../../../../components/templates/address-form" import { AddressPayload } from "../../../../components/templates/address-form"
import useImperativeDialog from "../../../../hooks/use-imperative-dialog" import useImperativeDialog from "../../../../hooks/use-imperative-dialog"
import useNotification from "../../../../hooks/use-notification" import useNotification from "../../../../hooks/use-notification"
import { useFeatureFlag } from "../../../../providers/feature-flag-provider"
import { getErrorMessage } from "../../../../utils/error-messages" import { getErrorMessage } from "../../../../utils/error-messages"
import { nestedForm } from "../../../../utils/nested-form" import { nestedForm } from "../../../../utils/nested-form"
import ClaimTypeForm, { import ClaimTypeForm, {
@@ -35,6 +38,10 @@ export type CreateClaimFormType = {
return_items: ItemsToReturnFormType return_items: ItemsToReturnFormType
additional_items: ItemsToSendFormType additional_items: ItemsToSendFormType
return_shipping: ShippingFormType return_shipping: ShippingFormType
selected_location?: {
value: string
label: string
}
replacement_shipping: ShippingFormType replacement_shipping: ShippingFormType
shipping_address: AddressPayload shipping_address: AddressPayload
claim_type: ClaimTypeFormType claim_type: ClaimTypeFormType
@@ -50,6 +57,28 @@ const RegisterClaimMenu = ({ order, onClose }: Props) => {
const context = useLayeredModal() const context = useLayeredModal()
const { mutate, isLoading } = useAdminCreateClaim(order.id) const { mutate, isLoading } = useAdminCreateClaim(order.id)
const { isFeatureEnabled } = useFeatureFlag()
const isLocationFulfillmentEnabled =
isFeatureEnabled("inventoryService") &&
isFeatureEnabled("stockLocationService")
const {
stock_locations,
refetch: refetchLocations,
isLoading: isLoadingLocations,
} = useAdminStockLocations(
{},
{
enabled: isLocationFulfillmentEnabled,
}
)
useEffect(() => {
if (isLocationFulfillmentEnabled) {
refetchLocations()
}
}, [isLocationFulfillmentEnabled, refetchLocations])
const form = useForm<CreateClaimFormType>({ const form = useForm<CreateClaimFormType>({
defaultValues: getDefaultClaimValues(order), defaultValues: getDefaultClaimValues(order),
}) })
@@ -58,6 +87,7 @@ const RegisterClaimMenu = ({ order, onClose }: Props) => {
reset, reset,
formState: { isDirty }, formState: { isDirty },
setError, setError,
control,
} = form } = form
const notification = useNotification() const notification = useNotification()
@@ -91,6 +121,7 @@ const RegisterClaimMenu = ({ order, onClose }: Props) => {
const type = data.claim_type.type const type = data.claim_type.type
const returnShipping = data.return_shipping const returnShipping = data.return_shipping
const refundAmount = data.refund_amount?.amount const refundAmount = data.refund_amount?.amount
const returnLocation = data.selected_location?.value
const replacementShipping = const replacementShipping =
type === "replace" && data.replacement_shipping.option type === "replace" && data.replacement_shipping.option
@@ -184,6 +215,7 @@ const RegisterClaimMenu = ({ order, onClose }: Props) => {
province: data.shipping_address.province || undefined, province: data.shipping_address.province || undefined,
} }
: undefined, : undefined,
return_location_id: returnLocation,
shipping_methods: replacementShipping shipping_methods: replacementShipping
? [replacementShipping] ? [replacementShipping]
: undefined, : undefined,
@@ -239,6 +271,41 @@ const RegisterClaimMenu = ({ order, onClose }: Props) => {
isReturn={true} isReturn={true}
isClaim={true} isClaim={true}
/> />
{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 />
) : (
<Controller
control={control}
name={"selected_location"}
render={({ field: { value, onChange } }) => {
return (
<Select
className="mt-2"
placeholder="Select Location to Return to"
value={value}
isMulti={false}
onChange={onChange}
options={
stock_locations?.map((sl: StockLocationDTO) => ({
label: sl.name,
value: sl.id,
})) || []
}
/>
)
}}
/>
)}
</div>
)}
<ClaimTypeForm form={nestedForm(form, "claim_type")} /> <ClaimTypeForm form={nestedForm(form, "claim_type")} />
{watchedType === "replace" && ( {watchedType === "replace" && (
<> <>
@@ -3,7 +3,9 @@ import {
Order, Order,
ProductVariant, ProductVariant,
ReturnReason, ReturnReason,
StockLocationDTO,
} from "@medusajs/medusa" } from "@medusajs/medusa"
import { useAdminStockLocations } from "medusa-react"
import { import {
useAdminCreateSwap, useAdminCreateSwap,
useAdminOrder, useAdminOrder,
@@ -23,6 +25,7 @@ import Select from "../../../../components/molecules/select"
import RMAReturnProductsTable from "../../../../components/organisms/rma-return-product-table" import RMAReturnProductsTable from "../../../../components/organisms/rma-return-product-table"
import RMASelectProductTable from "../../../../components/organisms/rma-select-product-table" import RMASelectProductTable from "../../../../components/organisms/rma-select-product-table"
import useNotification from "../../../../hooks/use-notification" import useNotification from "../../../../hooks/use-notification"
import { useFeatureFlag } from "../../../../providers/feature-flag-provider"
import { Option } from "../../../../types/shared" import { Option } from "../../../../types/shared"
import { getErrorMessage } from "../../../../utils/error-messages" import { getErrorMessage } from "../../../../utils/error-messages"
import { formatAmountWithSymbol } from "../../../../utils/prices" import { formatAmountWithSymbol } from "../../../../utils/prices"
@@ -62,6 +65,32 @@ const SwapMenu: React.FC<SwapMenuProps> = ({ order, onDismiss }) => {
undefined undefined
) )
const [noNotification, setNoNotification] = useState(order.no_notification) const [noNotification, setNoNotification] = useState(order.no_notification)
const [selectedLocation, setSelectedLocation] = React.useState<{
value: string
label: string
} | null>(null)
const { isFeatureEnabled } = useFeatureFlag()
const isLocationFulfillmentEnabled =
isFeatureEnabled("inventoryService") &&
isFeatureEnabled("stockLocationService")
const {
stock_locations,
refetch: refetchLocations,
isLoading: isLoadingLocations,
} = useAdminStockLocations(
{},
{
enabled: isLocationFulfillmentEnabled,
}
)
React.useEffect(() => {
if (isLocationFulfillmentEnabled) {
refetchLocations()
}
}, [isLocationFulfillmentEnabled, refetchLocations])
const notification = useNotification() const notification = useNotification()
@@ -195,6 +224,10 @@ const SwapMenu: React.FC<SwapMenuProps> = ({ order, onDismiss }) => {
noNotification !== order.no_notification ? noNotification : undefined, noNotification !== order.no_notification ? noNotification : undefined,
} }
if (isLocationFulfillmentEnabled && selectedLocation) {
data.return_location_id = selectedLocation.value
}
if (shippingMethod) { if (shippingMethod) {
data.return_shipping = { data.return_shipping = {
option_id: shippingMethod.value, option_id: shippingMethod.value,
@@ -263,6 +296,32 @@ const SwapMenu: React.FC<SwapMenuProps> = ({ order, onDismiss }) => {
/> />
)} )}
</div> </div>
{isLocationFulfillmentEnabled && (
<div className="my-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>
)}
<div className="mt-8 flex items-center justify-between"> <div className="mt-8 flex items-center justify-between">
<h3 className="inter-base-semibold ">Items to send</h3> <h3 className="inter-base-semibold ">Items to send</h3>
{itemsToAdd.length === 0 ? ( {itemsToAdd.length === 0 ? (
@@ -437,6 +437,10 @@ export class AdminPostOrdersOrderClaimsReq {
@IsOptional() @IsOptional()
no_notification?: boolean no_notification?: boolean
@IsOptional()
@IsString()
return_location_id?: string
@IsObject() @IsObject()
@IsOptional() @IsOptional()
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
@@ -164,6 +164,7 @@ export default async (req, res) => {
idempotency_key: idempotencyKey.idempotency_key, idempotency_key: idempotencyKey.idempotency_key,
no_notification: validated.no_notification, no_notification: validated.no_notification,
allow_backorder: validated.allow_backorder, allow_backorder: validated.allow_backorder,
location_id: validated.return_location_id,
} }
) )
@@ -361,6 +362,10 @@ export class AdminPostOrdersOrderSwapsReq {
@IsOptional() @IsOptional()
no_notification?: boolean no_notification?: boolean
@IsOptional()
@IsString()
return_location_id?: string
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
allow_backorder?: boolean = true allow_backorder?: boolean = true
+2
View File
@@ -346,6 +346,7 @@ export default class ClaimService extends TransactionBaseService {
shipping_address, shipping_address,
shipping_address_id, shipping_address_id,
no_notification, no_notification,
return_location_id,
...rest ...rest
} = data } = data
@@ -485,6 +486,7 @@ export default class ClaimService extends TransactionBaseService {
), ),
shipping_method: return_shipping, shipping_method: return_shipping,
no_notification: evaluatedNoNotification, no_notification: evaluatedNoNotification,
location_id: return_location_id,
}) })
} }
+2
View File
@@ -309,6 +309,7 @@ class SwapService extends TransactionBaseService {
no_notification?: boolean no_notification?: boolean
idempotency_key?: string idempotency_key?: string
allow_backorder?: boolean allow_backorder?: boolean
location_id?: string
} = { no_notification: undefined } } = { no_notification: undefined }
): Promise<Swap | never> { ): Promise<Swap | never> {
const { no_notification, ...rest } = custom const { no_notification, ...rest } = custom
@@ -379,6 +380,7 @@ class SwapService extends TransactionBaseService {
items: returnItems as OrdersReturnItem[], items: returnItems as OrdersReturnItem[],
shipping_method: returnShipping, shipping_method: returnShipping,
no_notification: evaluatedNoNotification, no_notification: evaluatedNoNotification,
location_id: custom.location_id,
}) })
await this.eventBus_ await this.eventBus_
+1
View File
@@ -18,6 +18,7 @@ export type CreateClaimInput = {
order: Order order: Order
claim_order_id?: string claim_order_id?: string
shipping_address_id?: string shipping_address_id?: string
return_location_id?: string
} }
type CreateClaimReturnShippingInput = { type CreateClaimReturnShippingInput = {