feat(core-flows, fulfillment): Add create return specific method and add more tests (#7357)

* feat(core-flows, fulfillment): Add create return specific method and add more tests

* fix defautl providers in tests fixtures

* more tests

* wip fixes

* fix flow and tests

* cleanup
This commit is contained in:
Adrien de Peretti
2024-05-21 13:48:59 +02:00
committed by GitHub
parent 35dc3c5cf7
commit c4fde7ea5c
9 changed files with 318 additions and 91 deletions

View File

@@ -1,5 +1,10 @@
import { ModuleRegistrationName, Modules } from "@medusajs/modules-sdk"
import {
ModuleRegistrationName,
Modules,
RemoteLink,
} from "@medusajs/modules-sdk"
import {
FulfillmentSetDTO,
FulfillmentWorkflow,
IOrderModuleService,
IRegionModuleService,
@@ -208,6 +213,7 @@ async function prepareDataFixtures({ container }) {
salesChannel,
location,
product,
fulfillmentSet,
}
}
@@ -312,6 +318,17 @@ async function createOrderFixture({ container, product }) {
},
])
const returnReason = await orderService.createReturnReasons({
value: "Test reason",
label: "Test reason",
})
await orderService.createReturnReasons({
value: "Test child reason",
label: "Test child reason",
parent_return_reason_id: returnReason.id,
})
await orderService.applyPendingOrderActions(order.id)
order = await orderService.retrieve(order.id, {
@@ -335,6 +352,7 @@ medusaIntegrationTestRunner({
let region: RegionDTO
let location: StockLocationDTO
let product: ProductDTO
let fulfillmentSet: FulfillmentSetDTO
let orderService: IOrderModuleService
@@ -347,12 +365,18 @@ medusaIntegrationTestRunner({
region = fixtures.region
location = fixtures.location
product = fixtures.product
fulfillmentSet = fixtures.fulfillmentSet
orderService = container.resolve(ModuleRegistrationName.ORDER)
})
it("should create a return order", async () => {
const order = await createOrderFixture({ container, product })
const reasons = await orderService.listReturnReasons({})
const testReason = reasons.find(
(r) => r.value.toLowerCase() === "test child reason"
)!
const createReturnOrderData: OrderWorkflow.CreateOrderReturnWorkflowInput =
{
order_id: order.id,
@@ -363,6 +387,7 @@ medusaIntegrationTestRunner({
{
id: order.items![0].id,
quantity: 1,
reason_id: testReason.id,
},
],
}
@@ -468,6 +493,80 @@ medusaIntegrationTestRunner({
})
)
})
it("should fail when location is not linked", async () => {
const order = await createOrderFixture({ container, product })
const createReturnOrderData: OrderWorkflow.CreateOrderReturnWorkflowInput =
{
order_id: order.id,
return_shipping: {
option_id: shippingOption.id,
},
items: [
{
id: order.items![0].id,
quantity: 1,
},
],
}
// Remove the location link
const remoteLink = container.resolve(
ContainerRegistrationKeys.REMOTE_LINK
) as RemoteLink
await remoteLink.dismiss([
{
[Modules.STOCK_LOCATION]: {
stock_location_id: location.id,
},
[Modules.FULFILLMENT]: {
fulfillment_set_id: fulfillmentSet.id,
},
},
])
const { errors } = await createReturnOrderWorkflow(container).run({
input: createReturnOrderData,
throwOnError: false,
})
await expect(errors[0].error.message).toBe(
`Cannot create return without stock location, either provide a location or you should link the shipping option ${shippingOption.id} to a stock location.`
)
})
it("should fail when a reason with children is provided", async () => {
const order = await createOrderFixture({ container, product })
const reasons = await orderService.listReturnReasons({})
const testReason = reasons.find(
(r) => r.value.toLowerCase() === "test reason"
)!
const createReturnOrderData: OrderWorkflow.CreateOrderReturnWorkflowInput =
{
order_id: order.id,
return_shipping: {
option_id: shippingOption.id,
},
items: [
{
id: order.items![0].id,
quantity: 1,
reason_id: testReason.id,
},
],
}
const { errors } = await createReturnOrderWorkflow(container).run({
input: createReturnOrderData,
throwOnError: false,
})
expect(errors[0].error.message).toBe(
`Cannot apply return reason with id ${testReason.id} to order with id ${order.id}. Return reason has nested reasons.`
)
})
})
},
})

View File

@@ -7,6 +7,7 @@ import {
WithCalculatedPrice,
} from "@medusajs/types"
import {
createStep,
createWorkflow,
transform,
WorkflowData,
@@ -19,6 +20,7 @@ import {
MathBN,
MedusaError,
Modules,
remoteQueryObjectFromString,
} from "@medusajs/utils"
import { updateOrderTaxLinesStep } from "../steps"
import { createReturnStep } from "../steps/create-return"
@@ -55,7 +57,7 @@ function throwIfItemsDoesNotExistsInOrder({
}
}
function validateReturnReasons(
async function validateReturnReasons(
{
orderId,
inputItems,
@@ -66,24 +68,32 @@ function validateReturnReasons(
{ container }
) {
const reasonIds = inputItems.map((i) => i.reason_id).filter(Boolean)
if (!reasonIds.length) {
return
}
const remoteQuery = container.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
const returnReasons = remoteQuery({
entry_point: "return_reasons",
fields: ["return_reason_children.*"],
variables: { id: [inputItems.map((item) => item.reason_id)] },
const remoteQueryObject = remoteQueryObjectFromString({
entryPoint: "return_reasons",
fields: [
"id",
"parent_return_reason_id",
"parent_return_reason",
"return_reason_children.id",
],
variables: { id: [inputItems.map((item) => item.reason_id)], limit: null },
})
const returnReasons = await remoteQuery(remoteQueryObject)
const reasons = returnReasons.map((r) => r.id)
const hasInvalidReasons = reasons.filter(
// We do not allow for root reason to be applied
(reason) => reason.return_reason_children.length > 0
)
const hasInvalidReasons = returnReasons
.filter(
// We do not allow for root reason to be applied
(reason) => reason.return_reason_children.length > 0
)
.map((r) => r.id)
const hasNonExistingReasons = arrayDifference(reasonIds, reasons)
if (hasNonExistingReasons.length) {
@@ -95,7 +105,7 @@ function validateReturnReasons(
)
}
if (hasInvalidReasons.length()) {
if (hasInvalidReasons.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Cannot apply return reason with id ${hasInvalidReasons.join(
@@ -238,12 +248,34 @@ function prepareReturnShippingOptionQueryVariables({
return variables
}
const validationStep = createStep(
"create-return-order-validation",
async function (
{
order,
input,
}: {
order
input: OrderWorkflow.CreateOrderReturnWorkflowInput
},
context
) {
throwIfOrderIsCancelled({ order })
throwIfItemsDoesNotExistsInOrder({ order, inputItems: input.items })
await validateReturnReasons(
{ orderId: input.order_id, inputItems: input.items },
context
)
validateCustomRefundAmount({ order, refundAmount: input.refund_amount })
}
)
export const createReturnOrderWorkflowId = "create-return-order"
export const createReturnOrderWorkflow = createWorkflow(
createReturnOrderWorkflowId,
(
function (
input: WorkflowData<OrderWorkflow.CreateOrderReturnWorkflowInput>
): WorkflowData<void> => {
): WorkflowData<void> {
const order: OrderDTO = useRemoteQueryStep({
entry_point: "orders",
fields: [
@@ -259,19 +291,7 @@ export const createReturnOrderWorkflow = createWorkflow(
throw_if_key_not_found: true,
})
transform({ order }, throwIfOrderIsCancelled)
transform(
{ order, inputItems: input.items },
throwIfItemsDoesNotExistsInOrder
)
transform(
{ orderId: input.order_id, inputItems: input.items },
validateReturnReasons
)
transform(
{ order, refundAmount: input.refund_amount },
validateCustomRefundAmount
)
validationStep({ order, input })
const returnShippingOptionsVariables = transform(
{ input, order },

View File

@@ -2367,7 +2367,7 @@ export interface IFulfillmentModuleService extends IModuleService {
): Promise<[FulfillmentDTO[], number]>
/**
* This method creates a fulfillment.
* This method creates a fulfillment and call the provider to create a fulfillment.
*
* @param {CreateFulfillmentDTO} data - The fulfillment to be created.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
@@ -2405,6 +2405,45 @@ export interface IFulfillmentModuleService extends IModuleService {
sharedContext?: Context
): Promise<FulfillmentDTO>
/**
* This method creates a fulfillment and call the provider to create a return.
*
* @param {CreateFulfillmentDTO} data - The fulfillment to be created.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<FulfillmentDTO>} The created fulfillment.
*
* @example
* const fulfillment =
* await fulfillmentModuleService.createReturnFulfillment({
* location_id: "loc_123",
* provider_id: "webshipper",
* delivery_address: {
* address_1: "4120 Auto Park Cir",
* country_code: "us",
* },
* items: [
* {
* title: "Shirt",
* sku: "SHIRT",
* quantity: 1,
* barcode: "ABCED",
* },
* ],
* labels: [
* {
* tracking_number: "1234567",
* tracking_url: "https://example.com/tracking",
* label_url: "https://example.com/label",
* },
* ],
* order: {},
* })
*/
createReturnFulfillment(
data: CreateFulfillmentDTO,
sharedContext?: Context
): Promise<FulfillmentDTO>
/**
* This method updates an existing fulfillment.
*

View File

@@ -14,6 +14,10 @@ export class FulfillmentProviderServiceFixtures extends AbstractFulfillmentProvi
async getFulfillmentOptions(): Promise<any> {
return {}
}
async createReturnFulfillment(fulfillment): Promise<any> {
return {}
}
}
export const services = [FulfillmentProviderServiceFixtures]

View File

@@ -137,6 +137,63 @@ moduleIntegrationTestRunner({
})
)
})
it("should create a return fulfillment", async () => {
const shippingProfile = await service.createShippingProfiles({
name: "test",
type: "default",
})
const fulfillmentSet = await service.create({
name: "test",
type: "test-type",
})
const serviceZone = await service.createServiceZones({
name: "test",
fulfillment_set_id: fulfillmentSet.id,
})
const shippingOption = await service.createShippingOptions(
generateCreateShippingOptionsData({
provider_id: providerId,
service_zone_id: serviceZone.id,
shipping_profile_id: shippingProfile.id,
})
)
const fulfillment = await service.createReturnFulfillment(
generateCreateFulfillmentData({
provider_id: providerId,
shipping_option_id: shippingOption.id,
})
)
expect(fulfillment).toEqual(
expect.objectContaining({
id: expect.any(String),
packed_at: null,
shipped_at: null,
delivered_at: null,
canceled_at: null,
data: null,
provider_id: providerId,
shipping_option_id: shippingOption.id,
metadata: null,
delivery_address: expect.objectContaining({
id: expect.any(String),
}),
items: [
expect.objectContaining({
id: expect.any(String),
}),
],
labels: [
expect.objectContaining({
id: expect.any(String),
}),
],
})
)
})
})
describe("on cancel", () => {

View File

@@ -193,9 +193,7 @@ export default class FulfillmentModuleService<
return await this.baseRepository_.serialize<
FulfillmentTypes.ShippingOptionDTO[]
>(shippingOptions, {
populate: true,
})
>(shippingOptions)
}
@InjectManager("baseRepository_")
@@ -211,10 +209,7 @@ export default class FulfillmentModuleService<
)
return await this.baseRepository_.serialize<FulfillmentTypes.FulfillmentDTO>(
fulfillment,
{
populate: true,
}
fulfillment
)
}
@@ -232,9 +227,7 @@ export default class FulfillmentModuleService<
return await this.baseRepository_.serialize<
FulfillmentTypes.FulfillmentDTO[]
>(fulfillments, {
populate: true,
})
>(fulfillments)
}
@InjectManager("baseRepository_")
@@ -251,10 +244,7 @@ export default class FulfillmentModuleService<
return [
await this.baseRepository_.serialize<FulfillmentTypes.FulfillmentDTO[]>(
fulfillments,
{
populate: true,
}
fulfillments
),
count,
]
@@ -283,9 +273,7 @@ export default class FulfillmentModuleService<
return await this.baseRepository_.serialize<
FulfillmentTypes.FulfillmentSetDTO | FulfillmentTypes.FulfillmentSetDTO[]
>(createdFulfillmentSets, {
populate: true,
})
>(createdFulfillmentSets)
}
@InjectTransactionManager("baseRepository_")
@@ -351,9 +339,7 @@ export default class FulfillmentModuleService<
return await this.baseRepository_.serialize<
FulfillmentTypes.ServiceZoneDTO | FulfillmentTypes.ServiceZoneDTO[]
>(createdServiceZones, {
populate: true,
})
>(createdServiceZones)
}
@InjectTransactionManager("baseRepository_")
@@ -410,9 +396,7 @@ export default class FulfillmentModuleService<
return await this.baseRepository_.serialize<
FulfillmentTypes.ShippingOptionDTO | FulfillmentTypes.ShippingOptionDTO[]
>(createdShippingOptions, {
populate: true,
})
>(createdShippingOptions)
}
@InjectTransactionManager("baseRepository_")
@@ -469,9 +453,7 @@ export default class FulfillmentModuleService<
return await this.baseRepository_.serialize<
| FulfillmentTypes.ShippingProfileDTO
| FulfillmentTypes.ShippingProfileDTO[]
>(createdShippingProfiles, {
populate: true,
})
>(createdShippingProfiles)
}
@InjectTransactionManager("baseRepository_")
@@ -523,10 +505,7 @@ export default class FulfillmentModuleService<
)
return await this.baseRepository_.serialize<FulfillmentTypes.GeoZoneDTO[]>(
Array.isArray(data) ? createdGeoZones : createdGeoZones[0],
{
populate: true,
}
Array.isArray(data) ? createdGeoZones : createdGeoZones[0]
)
}
@@ -557,9 +536,7 @@ export default class FulfillmentModuleService<
return await this.baseRepository_.serialize<
| FulfillmentTypes.ShippingOptionRuleDTO
| FulfillmentTypes.ShippingOptionRuleDTO[]
>(createdShippingOptionRules, {
populate: true,
})
>(createdShippingOptionRules)
}
@InjectTransactionManager("baseRepository_")
@@ -627,10 +604,43 @@ export default class FulfillmentModuleService<
}
return await this.baseRepository_.serialize<FulfillmentTypes.FulfillmentDTO>(
fulfillment,
{
populate: true,
}
fulfillment
)
}
@InjectManager("baseRepository_")
async createReturnFulfillment(
data: FulfillmentTypes.CreateFulfillmentDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<FulfillmentTypes.FulfillmentDTO> {
const { order, ...fulfillmentDataToCreate } = data
const fulfillment = await this.fulfillmentService_.create(
fulfillmentDataToCreate,
sharedContext
)
let fulfillmentThirdPartyData!: any
try {
fulfillmentThirdPartyData =
await this.fulfillmentProviderService_.createReturn(
fulfillment.provider_id,
fulfillment as Record<any, any>
)
await this.fulfillmentService_.update(
{
id: fulfillment.id,
data: fulfillmentThirdPartyData ?? {},
},
sharedContext
)
} catch (error) {
await this.fulfillmentService_.delete(fulfillment.id, sharedContext)
throw error
}
return await this.baseRepository_.serialize<FulfillmentTypes.FulfillmentDTO>(
fulfillment
)
}
@@ -654,9 +664,7 @@ export default class FulfillmentModuleService<
return await this.baseRepository_.serialize<
FulfillmentTypes.FulfillmentSetDTO | FulfillmentTypes.FulfillmentSetDTO[]
>(updatedFulfillmentSets, {
populate: true,
})
>(updatedFulfillmentSets)
}
@InjectTransactionManager("baseRepository_")
@@ -865,9 +873,7 @@ export default class FulfillmentModuleService<
return await this.baseRepository_.serialize<
FulfillmentTypes.ServiceZoneDTO | FulfillmentTypes.ServiceZoneDTO[]
>(toReturn, {
populate: true,
})
>(toReturn)
}
@InjectTransactionManager("baseRepository_")
@@ -1110,9 +1116,7 @@ export default class FulfillmentModuleService<
const serialized = await this.baseRepository_.serialize<
FulfillmentTypes.ShippingOptionDTO | FulfillmentTypes.ShippingOptionDTO[]
>(updatedShippingOptions, {
populate: true,
})
>(updatedShippingOptions)
return isString(idOrSelector) ? serialized[0] : serialized
}
@@ -1359,9 +1363,7 @@ export default class FulfillmentModuleService<
const serialized = await this.baseRepository_.serialize<
FulfillmentTypes.GeoZoneDTO[]
>(updatedGeoZones, {
populate: true,
})
>(updatedGeoZones)
return Array.isArray(data) ? serialized : serialized[0]
}
@@ -1393,9 +1395,7 @@ export default class FulfillmentModuleService<
return await this.baseRepository_.serialize<
| FulfillmentTypes.ShippingOptionRuleDTO
| FulfillmentTypes.ShippingOptionRuleDTO[]
>(updatedShippingOptionRules, {
populate: true,
})
>(updatedShippingOptionRules)
}
@InjectTransactionManager("baseRepository_")
@@ -1434,10 +1434,7 @@ export default class FulfillmentModuleService<
const serialized =
await this.baseRepository_.serialize<FulfillmentTypes.FulfillmentDTO>(
fulfillment,
{
populate: true,
}
fulfillment
)
return Array.isArray(serialized) ? serialized[0] : serialized
@@ -1478,9 +1475,7 @@ export default class FulfillmentModuleService<
)
}
const result = await this.baseRepository_.serialize(fulfillment, {
populate: true,
})
const result = await this.baseRepository_.serialize(fulfillment)
return Array.isArray(result) ? result[0] : result
}

View File

@@ -101,4 +101,12 @@ export default class FulfillmentProviderService extends ModulesSdkUtils.internal
const provider = this.retrieveProviderRegistration(providerId)
return await provider.cancelFulfillment(fulfillment)
}
async createReturn(
providerId: string,
fulfillment: Record<string, unknown>,
) {
const provider = this.retrieveProviderRegistration(providerId)
return await provider.createReturnFulfillment(fulfillment)
}
}

View File

@@ -1,6 +1,6 @@
import { Modules } from "@medusajs/modules-sdk"
import { ModuleJoinerConfig } from "@medusajs/types"
import { MapToConfig } from "@medusajs/utils"
import { MapToConfig, pluralize } from "@medusajs/utils"
import { LineItem, ReturnReason } from "@models"
import Order from "./models/order"
@@ -37,6 +37,7 @@ export const joinerConfig: ModuleJoinerConfig = {
name: ["return_reason", "return_reasons"],
args: {
entity: ReturnReason.name,
methodSuffix: pluralize(ReturnReason.name),
},
},
],

View File

@@ -29,16 +29,20 @@ export class ManualFulfillmentService extends AbstractFulfillmentProviderService
return data
}
async validateOption(data: Record<string, unknown>): Promise<boolean> {
async validateOption(data: Record<string, any>): Promise<boolean> {
return true
}
async createFulfillment(): Promise<Record<string, unknown>> {
async createFulfillment(): Promise<Record<string, any>> {
// No data is being sent anywhere
return {}
}
async cancelFulfillment(fulfillment: Record<string, unknown>): Promise<any> {
async cancelFulfillment(): Promise<any> {
return {}
}
async createReturnFulfillment(): Promise<any> {
return {}
}
}