feat(order): cancel fulfillment (#7573)

This commit is contained in:
Carlos R. L. Rodrigues
2024-06-02 09:33:24 -03:00
committed by GitHub
parent 4e04214612
commit af0140d317
30 changed files with 745 additions and 298 deletions

View File

@@ -1,4 +1,5 @@
import {
cancelOrderFulfillmentWorkflow,
createOrderFulfillmentWorkflow,
createShippingOptionsWorkflow,
} from "@medusajs/core-flows"
@@ -314,7 +315,7 @@ medusaIntegrationTestRunner({
container = getContainer()
})
describe("Create order fulfillment workflow", () => {
describe("Order fulfillment workflow", () => {
let shippingOption: ShippingOptionDTO
let region: RegionDTO
let location: StockLocationDTO
@@ -335,9 +336,11 @@ medusaIntegrationTestRunner({
orderService = container.resolve(ModuleRegistrationName.ORDER)
})
it("should create a order fulfillment", async () => {
it("should create a order fulfillment and cancel it", async () => {
const order = await createOrderFixture({ container, product, location })
const createReturnOrderData: OrderWorkflow.CreateOrderFulfillmentWorkflowInput =
// Create a fulfillment
const createOrderFulfillmentData: OrderWorkflow.CreateOrderFulfillmentWorkflowInput =
{
order_id: order.id,
created_by: "user_1",
@@ -352,7 +355,7 @@ medusaIntegrationTestRunner({
}
await createOrderFulfillmentWorkflow(container).run({
input: createReturnOrderData,
input: createOrderFulfillmentData,
})
const remoteQuery = container.resolve(
@@ -391,6 +394,48 @@ medusaIntegrationTestRunner({
[location.id]
)
expect(stockAvailability).toEqual(1)
// Cancel the fulfillment
const cancelFulfillmentData: OrderWorkflow.CancelOrderFulfillmentWorkflowInput =
{
order_id: order.id,
fulfillment_id: orderFulfill.fulfillments[0].id,
no_notification: false,
}
await cancelOrderFulfillmentWorkflow(container).run({
input: cancelFulfillmentData,
})
const remoteQueryObjectFulfill = remoteQueryObjectFromString({
entryPoint: "order",
variables: {
id: order.id,
},
fields: [
"*",
"items.*",
"shipping_methods.*",
"total",
"item_total",
"fulfillments.*",
],
})
const [orderFulfillAfterCancelled] = await remoteQuery(
remoteQueryObjectFulfill
)
expect(orderFulfillAfterCancelled.fulfillments).toHaveLength(1)
expect(
orderFulfillAfterCancelled.items[0].detail.fulfilled_quantity
).toEqual(0)
const stockAvailabilityAfterCancelled =
await inventoryModule.retrieveStockedQuantity(inventoryItem.id, [
location.id,
])
expect(stockAvailabilityAfterCancelled).toEqual(2)
})
})
},

View File

@@ -1,12 +1,12 @@
import { LinkDefinition, RemoteLink } from "@medusajs/modules-sdk"
import { createStep, StepResponse } from "@medusajs/workflows-sdk"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
import { ContainerRegistrationKeys } from "@medusajs/utils"
type CreateRemoteLinksStepInput = LinkDefinition[]
export const createLinksStepId = "create-links"
export const createLinkStep = createStep(
export const createLinksStepId = "create-remote-links"
export const createRemoteLinkStep = createStep(
createLinksStepId,
async (data: CreateRemoteLinksStepInput, { container }) => {
const link = container.resolve<RemoteLink>(

View File

@@ -0,0 +1,29 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { CancelOrderFulfillmentDTO, IOrderModuleService } from "@medusajs/types"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
type CancelOrderFulfillmentStepInput = CancelOrderFulfillmentDTO
export const cancelOrderFulfillmentStepId = "cancel-order-fullfillment"
export const cancelOrderFulfillmentStep = createStep(
cancelOrderFulfillmentStepId,
async (data: CancelOrderFulfillmentStepInput, { container }) => {
const service = container.resolve<IOrderModuleService>(
ModuleRegistrationName.ORDER
)
await service.cancelFulfillment(data)
return new StepResponse(void 0, data.order_id)
},
async (orderId, { container }) => {
if (!orderId) {
return
}
const service = container.resolve<IOrderModuleService>(
ModuleRegistrationName.ORDER
)
await service.revertLastVersion(orderId)
}
)

View File

@@ -0,0 +1,148 @@
import { Modules } from "@medusajs/modules-sdk"
import { FulfillmentDTO, OrderDTO, OrderWorkflow } from "@medusajs/types"
import { MedusaError } from "@medusajs/utils"
import {
WorkflowData,
createStep,
createWorkflow,
transform,
} from "@medusajs/workflows-sdk"
import { useRemoteQueryStep } from "../../common"
import { cancelFulfillmentWorkflow } from "../../fulfillment"
import { adjustInventoryLevelsStep } from "../../inventory"
import { cancelOrderFulfillmentStep } from "../steps/cancel-fulfillment"
import {
throwIfItemsDoesNotExistsInOrder,
throwIfOrderIsCancelled,
} from "../utils/order-validation"
const validateOrder = createStep(
"validate-order",
({
order,
input,
}: {
order: OrderDTO & { fulfillments: FulfillmentDTO[] }
input: OrderWorkflow.CancelOrderFulfillmentWorkflowInput
}) => {
throwIfOrderIsCancelled({ order })
const fulfillment = order.fulfillments.find(
(f) => f.id === input.fulfillment_id
)
if (!fulfillment) {
throw new Error(
`Fulfillment with id ${input.fulfillment_id} not found in the order`
)
}
if (fulfillment.shipped_at) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`The fulfillment has already been shipped. Shipped fulfillments cannot be canceled`
)
}
throwIfItemsDoesNotExistsInOrder({
order,
inputItems: fulfillment.items.map((i) => ({
id: i.line_item_id as string,
quantity: i.quantity,
})),
})
}
)
function prepareCancelOrderFulfillmentData({
order,
fulfillment,
}: {
order: OrderDTO
fulfillment: FulfillmentDTO
}) {
return {
order_id: order.id,
reference: Modules.FULFILLMENT,
reference_id: fulfillment.id,
items: fulfillment.items!.map((i) => {
return {
id: i.line_item_id as string,
quantity: i.quantity,
}
}),
}
}
function prepareInventoryUpdate({
fulfillment,
}: {
order: OrderDTO
fulfillment: FulfillmentDTO
}) {
const inventoryAdjustment: {
inventory_item_id: string
location_id: string
adjustment: number // TODO: BigNumberInput
}[] = []
for (const item of fulfillment.items) {
inventoryAdjustment.push({
inventory_item_id: item.inventory_item_id as string,
location_id: fulfillment.location_id,
adjustment: item.quantity,
})
}
return {
inventoryAdjustment,
}
}
export const cancelOrderFulfillmentWorkflowId = "cancel-order-fulfillment"
export const cancelOrderFulfillmentWorkflow = createWorkflow(
cancelOrderFulfillmentWorkflowId,
(
input: WorkflowData<OrderWorkflow.CancelOrderFulfillmentWorkflowInput>
): WorkflowData<void> => {
const order: OrderDTO & { fulfillments: FulfillmentDTO[] } =
useRemoteQueryStep({
entry_point: "orders",
fields: [
"id",
"status",
"items.*",
"fulfillments.*",
"fulfillments.items.*",
],
variables: { id: input.order_id },
list: false,
throw_if_key_not_found: true,
})
validateOrder({ order, input })
const fulfillment = transform({ input, order }, ({ input, order }) => {
return order.fulfillments.find((f) => f.id === input.fulfillment_id)!
})
cancelFulfillmentWorkflow.runAsStep({
input: {
id: input.fulfillment_id,
},
})
const cancelOrderFulfillmentData = transform(
{ order, fulfillment },
prepareCancelOrderFulfillmentData
)
cancelOrderFulfillmentStep(cancelOrderFulfillmentData)
const { inventoryAdjustment } = transform(
{ order, fulfillment },
prepareInventoryUpdate
)
adjustInventoryLevelsStep(inventoryAdjustment)
}
)

View File

@@ -4,6 +4,7 @@ import {
FulfillmentWorkflow,
OrderDTO,
OrderWorkflow,
ReservationItemDTO,
} from "@medusajs/types"
import { MedusaError } from "@medusajs/utils"
import {
@@ -13,7 +14,7 @@ import {
parallelize,
transform,
} from "@medusajs/workflows-sdk"
import { createLinkStep, useRemoteQueryStep } from "../../common"
import { createRemoteLinkStep, useRemoteQueryStep } from "../../common"
import { createFulfillmentWorkflow } from "../../fulfillment"
import { adjustInventoryLevelsStep } from "../../inventory"
import {
@@ -70,6 +71,7 @@ function prepareFulfillmentData({
order,
input,
shippingOption,
reservations,
}: {
order: OrderDTO
input: OrderWorkflow.CreateOrderFulfillmentWorkflowInput
@@ -78,15 +80,21 @@ function prepareFulfillmentData({
provider_id: string
service_zone: { fulfillment_set: { location?: { id: string } } }
}
reservations: ReservationItemDTO[]
}) {
const inputItems = input.items
const orderItemsMap = new Map<string, Required<OrderDTO>["items"][0]>(
order.items!.map((i) => [i.id, i])
)
const reservationItemMap = new Map<string, ReservationItemDTO>(
reservations.map((r) => [r.line_item_id as string, r])
)
const fulfillmentItems = inputItems.map((i) => {
const orderItem = orderItemsMap.get(i.id)!
const reservation = reservationItemMap.get(i.id)!
return {
line_item_id: i.id,
inventory_item_id: reservation.inventory_item_id,
quantity: i.quantity,
title: orderItem.variant_title ?? orderItem.title,
sku: orderItem.variant_sku || "",
@@ -118,7 +126,7 @@ function prepareFulfillmentData({
}
}
function prepareInventoryReservations({ reservations, order, input }) {
function prepareInventoryUpdate({ reservations, order, input }) {
if (!reservations || !reservations.length) {
throw new Error(
`No stock reservation found for items ${input.items.map((i) => i.id)}`
@@ -215,8 +223,27 @@ export const createOrderFulfillmentWorkflow = createWorkflow(
throw_if_key_not_found: true,
}).config({ name: "get-shipping-option" })
const lineItemIds = transform({ order }, ({ order }) => {
return order.items?.map((i) => i.id)
})
const reservations = useRemoteQueryStep({
entry_point: "reservations",
fields: [
"id",
"line_item_id",
"quantity",
"inventory_item_id",
"location_id",
],
variables: {
filter: {
line_item_id: lineItemIds,
},
},
}).config({ name: "get-reservations" })
const fulfillmentData = transform(
{ order, input, shippingOption },
{ order, input, shippingOption, reservations },
prepareFulfillmentData
)
@@ -240,30 +267,11 @@ export const createOrderFulfillmentWorkflow = createWorkflow(
]
}
)
createLinkStep(link)
const lineItemIds = transform({ order }, ({ order }) => {
return order.items?.map((i) => i.id)
})
const reservations = useRemoteQueryStep({
entry_point: "reservations",
fields: [
"id",
"line_item_id",
"quantity",
"inventory_item_id",
"location_id",
],
variables: {
filter: {
line_item_id: lineItemIds,
},
},
}).config({ name: "get-reservations" })
createRemoteLinkStep(link)
const { toDelete, toUpdate, inventoryAdjustment } = transform(
{ order, reservations, input },
prepareInventoryReservations
prepareInventoryUpdate
)
parallelize(

View File

@@ -21,7 +21,7 @@ import {
createWorkflow,
transform,
} from "@medusajs/workflows-sdk"
import { createLinkStep, useRemoteQueryStep } from "../../common"
import { createRemoteLinkStep, useRemoteQueryStep } from "../../common"
import { createReturnFulfillmentWorkflow } from "../../fulfillment"
import { updateOrderTaxLinesStep } from "../steps"
import { createReturnStep } from "../steps/create-return"
@@ -326,6 +326,6 @@ export const createReturnOrderWorkflow = createWorkflow(
]
}
)
createLinkStep(link)
createRemoteLinkStep(link)
}
)

View File

@@ -1,4 +1,5 @@
export * from "./archive-orders"
export * from "./cancel-order-fulfillment"
export * from "./complete-orders"
export * from "./create-fulfillment"
export * from "./create-orders"

View File

@@ -1,18 +1,18 @@
import { updateProductsStep } from "../steps/update-products"
import {
dismissRemoteLinkStep,
createLinkStep,
useRemoteQueryStep,
} from "../../common"
import { arrayDifference } from "@medusajs/utils"
import { Modules } from "@medusajs/modules-sdk"
import { ProductTypes } from "@medusajs/types"
import { arrayDifference } from "@medusajs/utils"
import {
WorkflowData,
createWorkflow,
transform,
} from "@medusajs/workflows-sdk"
import {
createRemoteLinkStep,
dismissRemoteLinkStep,
useRemoteQueryStep,
} from "../../common"
type UpdateProductsStepInputSelector = {
selector: ProductTypes.FilterableProductProps
@@ -161,7 +161,7 @@ export const updateProductsWorkflow = createWorkflow(
prepareSalesChannelLinks
)
createLinkStep(salesChannelLinks)
createRemoteLinkStep(salesChannelLinks)
return updatedProducts
}

View File

@@ -8,6 +8,7 @@ export type ChangeActionType =
| "CANCEL"
| "CANCEL_RETURN"
| "FULFILL_ITEM"
| "CANCEL_ITEM_FULFILLMENT"
| "ITEM_ADD"
| "ITEM_REMOVE"
| "RECEIVE_DAMAGED_RETURN_ITEM"

View File

@@ -368,7 +368,7 @@ export interface UpdateOrderItemWithSelectorDTO {
/** ORDER bundled action flows */
export interface RegisterOrderFulfillmentDTO {
interface BaseOrderBundledActionsDTO {
order_id: string
description?: string
internal_note?: string
@@ -384,54 +384,18 @@ export interface RegisterOrderFulfillmentDTO {
metadata?: Record<string, unknown> | null
}
export interface RegisterOrderShipmentDTO {
order_id: string
description?: string
internal_note?: string
reference?: string
reference_id?: string
created_by?: string
items: {
id: string
quantity: BigNumberInput
internal_note?: string
metadata?: Record<string, unknown> | null
}[]
metadata?: Record<string, unknown> | null
}
export interface RegisterOrderFulfillmentDTO
extends BaseOrderBundledActionsDTO {}
export interface CreateOrderReturnDTO {
order_id: string
description?: string
reference?: string
reference_id?: string
internal_note?: string
created_by?: string
export interface CancelOrderFulfillmentDTO extends BaseOrderBundledActionsDTO {}
export interface RegisterOrderShipmentDTO extends BaseOrderBundledActionsDTO {}
export interface CreateOrderReturnDTO extends BaseOrderBundledActionsDTO {
shipping_method: Omit<CreateOrderShippingMethodDTO, "order_id"> | string
items: {
id: string
quantity: BigNumberInput
internal_note?: string
metadata?: Record<string, unknown> | null
}[]
metadata?: Record<string, unknown> | null
}
export interface ReceiveOrderReturnDTO {
order_id: string
description?: string
internal_note?: string
reference?: string
reference_id?: string
created_by?: string
items: {
id: string
quantity: BigNumberInput
internal_note?: string
metadata?: Record<string, unknown> | null
}[]
metadata?: Record<string, unknown> | null
}
export interface ReceiveOrderReturnDTO extends BaseOrderBundledActionsDTO {}
/** ORDER bundled action flows */

View File

@@ -29,6 +29,7 @@ import {
} from "./common"
import {
CancelOrderChangeDTO,
CancelOrderFulfillmentDTO,
ConfirmOrderChangeDTO,
CreateOrderAddressDTO,
CreateOrderAdjustmentDTO,
@@ -1496,6 +1497,11 @@ export interface IOrderModuleService extends IModuleService {
sharedContext?: Context
): Promise<void>
cancelFulfillment(
data: CancelOrderFulfillmentDTO,
sharedContext?: Context
): Promise<void>
registerShipment(
data: RegisterOrderShipmentDTO,
sharedContext?: Context

View File

@@ -0,0 +1,5 @@
export interface CancelOrderFulfillmentWorkflowInput {
order_id: string
fulfillment_id: string
no_notification?: boolean
}

View File

@@ -1,3 +1,4 @@
export * from "./cancel-fulfillment"
export * from "./create-fulfillment"
export * from "./create-return-order"
export * from "./create-shipment"

View File

@@ -1,3 +1,4 @@
import { cancelOrderFulfillmentWorkflow } from "@medusajs/core-flows"
import {
ContainerRegistrationKeys,
remoteQueryObjectFromString,
@@ -6,16 +7,29 @@ import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "../../../../../../../types/routing"
import { AdminOrderCancelFulfillmentType } from "../../../../validators"
export const POST = async (
req: AuthenticatedMedusaRequest,
req: AuthenticatedMedusaRequest<AdminOrderCancelFulfillmentType>,
res: MedusaResponse
) => {
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
const variables = { id: req.params.id }
// TODO: Workflow to cancel fulfillment + adjust inventory
const input = {
...req.validatedBody,
order_id: req.params.id,
}
const { errors } = await cancelOrderFulfillmentWorkflow(req.scope).run({
input,
throwOnError: false,
})
if (Array.isArray(errors) && errors[0]) {
throw errors[0].error
}
const queryObject = remoteQueryObjectFromString({
entryPoint: "order",

View File

@@ -79,3 +79,12 @@ export const AdminOrderCreateShipment = z.object({
export type AdminOrderCreateShipmentType = z.infer<
typeof AdminOrderCreateShipment
>
export const AdminOrderCancelFulfillment = z.object({
fulfillment_id: z.string(),
no_notification: z.boolean().optional(),
})
export type AdminOrderCancelFulfillmentType = z.infer<
typeof AdminOrderCancelFulfillment
>

View File

@@ -418,208 +418,6 @@ moduleIntegrationTestRunner({
)
expect(orders4.length).toEqual(0)
})
it("should create an order, fulfill, ship and return the items", async function () {
const createdOrder = await service.create(input)
// Fullfilment
await service.registerFulfillment({
order_id: createdOrder.id,
items: createdOrder.items!.map((item) => {
return {
id: item.id,
quantity: item.quantity,
}
}),
})
let getOrder = await service.retrieve(createdOrder.id, {
select: [
"id",
"version",
"items.id",
"items.quantity",
"items.detail.id",
"items.detail.version",
"items.detail.quantity",
"items.detail.shipped_quantity",
"items.detail.fulfilled_quantity",
],
relations: ["items", "items.detail"],
})
let serializedOrder = JSON.parse(JSON.stringify(getOrder))
expect(serializedOrder).toEqual(
expect.objectContaining({
version: 2,
items: [
expect.objectContaining({
quantity: 1,
detail: expect.objectContaining({
version: 2,
quantity: 1,
fulfilled_quantity: 1,
shipped_quantity: 0,
}),
}),
expect.objectContaining({
quantity: 2,
detail: expect.objectContaining({
version: 2,
quantity: 2,
fulfilled_quantity: 2,
shipped_quantity: 0,
}),
}),
expect.objectContaining({
quantity: 1,
detail: expect.objectContaining({
version: 2,
quantity: 1,
fulfilled_quantity: 1,
shipped_quantity: 0,
}),
}),
],
})
)
// Shipment
await service.registerShipment({
order_id: createdOrder.id,
reference: Modules.FULFILLMENT,
items: createdOrder.items!.map((item) => {
return {
id: item.id,
quantity: item.quantity,
}
}),
})
getOrder = await service.retrieve(createdOrder.id, {
select: [
"id",
"version",
"items.id",
"items.quantity",
"items.detail.id",
"items.detail.version",
"items.detail.quantity",
"items.detail.shipped_quantity",
"items.detail.fulfilled_quantity",
],
relations: ["items", "items.detail"],
})
serializedOrder = JSON.parse(JSON.stringify(getOrder))
expect(serializedOrder).toEqual(
expect.objectContaining({
version: 3,
items: [
expect.objectContaining({
quantity: 1,
detail: expect.objectContaining({
version: 3,
quantity: 1,
fulfilled_quantity: 1,
shipped_quantity: 1,
}),
}),
expect.objectContaining({
quantity: 2,
detail: expect.objectContaining({
version: 3,
quantity: 2,
fulfilled_quantity: 2,
shipped_quantity: 2,
}),
}),
expect.objectContaining({
quantity: 1,
detail: expect.objectContaining({
version: 3,
quantity: 1,
fulfilled_quantity: 1,
shipped_quantity: 1,
}),
}),
],
})
)
// Return
await service.createReturn({
order_id: createdOrder.id,
reference: Modules.FULFILLMENT,
description: "Return all the items",
internal_note: "user wants to return all items",
shipping_method: createdOrder.shipping_methods![0].id,
items: createdOrder.items!.map((item) => {
return {
id: item.id,
quantity: item.quantity,
}
}),
})
getOrder = await service.retrieve(createdOrder.id, {
select: [
"id",
"version",
"items.id",
"items.quantity",
"items.detail.id",
"items.detail.version",
"items.detail.quantity",
"items.detail.shipped_quantity",
"items.detail.fulfilled_quantity",
"items.detail.return_requested_quantity",
],
relations: ["items", "items.detail"],
})
serializedOrder = JSON.parse(JSON.stringify(getOrder))
expect(serializedOrder).toEqual(
expect.objectContaining({
version: 4,
items: [
expect.objectContaining({
quantity: 1,
detail: expect.objectContaining({
version: 4,
quantity: 1,
fulfilled_quantity: 1,
shipped_quantity: 1,
return_requested_quantity: 1,
}),
}),
expect.objectContaining({
quantity: 2,
detail: expect.objectContaining({
version: 4,
quantity: 2,
fulfilled_quantity: 2,
shipped_quantity: 2,
return_requested_quantity: 2,
}),
}),
expect.objectContaining({
quantity: 1,
detail: expect.objectContaining({
version: 4,
quantity: 1,
fulfilled_quantity: 1,
shipped_quantity: 1,
return_requested_quantity: 1,
}),
}),
],
})
)
})
})
},
})

View File

@@ -0,0 +1,308 @@
import { Modules } from "@medusajs/modules-sdk"
import { CreateOrderDTO, IOrderModuleService } from "@medusajs/types"
import { SuiteOptions, moduleIntegrationTestRunner } from "medusa-test-utils"
jest.setTimeout(100000)
moduleIntegrationTestRunner({
moduleName: Modules.ORDER,
testSuite: ({ service }: SuiteOptions<IOrderModuleService>) => {
describe("Order Module Service - Return flows", () => {
const input = {
email: "foo@bar.com",
items: [
{
title: "Item 1",
subtitle: "Subtitle 1",
thumbnail: "thumbnail1.jpg",
quantity: 1,
product_id: "product1",
product_title: "Product 1",
product_description: "Description 1",
product_subtitle: "Product Subtitle 1",
product_type: "Type 1",
product_collection: "Collection 1",
product_handle: "handle1",
variant_id: "variant1",
variant_sku: "SKU1",
variant_barcode: "Barcode1",
variant_title: "Variant 1",
variant_option_values: {
color: "Red",
size: "Large",
},
requires_shipping: true,
is_discountable: true,
is_tax_inclusive: true,
compare_at_unit_price: 10,
unit_price: 8,
tax_lines: [
{
description: "Tax 1",
tax_rate_id: "tax_usa",
code: "code",
rate: 0.1,
provider_id: "taxify_master",
},
],
adjustments: [
{
code: "VIP_10",
amount: 10,
description: "VIP discount",
promotion_id: "prom_123",
provider_id: "coupon_kings",
},
],
},
{
title: "Item 2",
quantity: 2,
unit_price: 5,
},
{
title: "Item 3",
quantity: 1,
unit_price: 30,
},
],
sales_channel_id: "test",
shipping_address: {
first_name: "Test",
last_name: "Test",
address_1: "Test",
city: "Test",
country_code: "US",
postal_code: "12345",
phone: "12345",
},
billing_address: {
first_name: "Test",
last_name: "Test",
address_1: "Test",
city: "Test",
country_code: "US",
postal_code: "12345",
},
shipping_methods: [
{
name: "Test shipping method",
amount: 10,
},
],
transactions: [
{
amount: 58,
currency_code: "USD",
reference: "payment",
reference_id: "pay_123",
},
],
currency_code: "usd",
customer_id: "joe",
} as CreateOrderDTO
it("should create an order, fulfill, ship and return the items and cancel some item return", async function () {
const createdOrder = await service.create(input)
// Fullfilment
await service.registerFulfillment({
order_id: createdOrder.id,
items: createdOrder.items!.map((item) => {
return {
id: item.id,
quantity: item.quantity,
}
}),
})
let getOrder = await service.retrieve(createdOrder.id, {
select: [
"id",
"version",
"items.id",
"items.quantity",
"items.detail.id",
"items.detail.version",
"items.detail.quantity",
"items.detail.shipped_quantity",
"items.detail.fulfilled_quantity",
],
relations: ["items", "items.detail"],
})
let serializedOrder = JSON.parse(JSON.stringify(getOrder))
expect(serializedOrder).toEqual(
expect.objectContaining({
version: 2,
items: [
expect.objectContaining({
quantity: 1,
detail: expect.objectContaining({
version: 2,
quantity: 1,
fulfilled_quantity: 1,
shipped_quantity: 0,
}),
}),
expect.objectContaining({
quantity: 2,
detail: expect.objectContaining({
version: 2,
quantity: 2,
fulfilled_quantity: 2,
shipped_quantity: 0,
}),
}),
expect.objectContaining({
quantity: 1,
detail: expect.objectContaining({
version: 2,
quantity: 1,
fulfilled_quantity: 1,
shipped_quantity: 0,
}),
}),
],
})
)
// Shipment
await service.registerShipment({
order_id: createdOrder.id,
reference: Modules.FULFILLMENT,
items: createdOrder.items!.map((item) => {
return {
id: item.id,
quantity: item.quantity,
}
}),
})
getOrder = await service.retrieve(createdOrder.id, {
select: [
"id",
"version",
"items.id",
"items.quantity",
"items.detail.id",
"items.detail.version",
"items.detail.quantity",
"items.detail.shipped_quantity",
"items.detail.fulfilled_quantity",
],
relations: ["items", "items.detail"],
})
serializedOrder = JSON.parse(JSON.stringify(getOrder))
expect(serializedOrder).toEqual(
expect.objectContaining({
version: 3,
items: [
expect.objectContaining({
quantity: 1,
detail: expect.objectContaining({
version: 3,
quantity: 1,
fulfilled_quantity: 1,
shipped_quantity: 1,
}),
}),
expect.objectContaining({
quantity: 2,
detail: expect.objectContaining({
version: 3,
quantity: 2,
fulfilled_quantity: 2,
shipped_quantity: 2,
}),
}),
expect.objectContaining({
quantity: 1,
detail: expect.objectContaining({
version: 3,
quantity: 1,
fulfilled_quantity: 1,
shipped_quantity: 1,
}),
}),
],
})
)
// Return
await service.createReturn({
order_id: createdOrder.id,
reference: Modules.FULFILLMENT,
description: "Return all the items",
internal_note: "user wants to return all items",
shipping_method: createdOrder.shipping_methods![0].id,
items: createdOrder.items!.map((item) => {
return {
id: item.id,
quantity: item.quantity,
}
}),
})
getOrder = await service.retrieve(createdOrder.id, {
select: [
"id",
"version",
"items.id",
"items.quantity",
"items.detail.id",
"items.detail.version",
"items.detail.quantity",
"items.detail.shipped_quantity",
"items.detail.fulfilled_quantity",
"items.detail.return_requested_quantity",
],
relations: ["items", "items.detail"],
})
serializedOrder = JSON.parse(JSON.stringify(getOrder))
expect(serializedOrder).toEqual(
expect.objectContaining({
version: 4,
items: [
expect.objectContaining({
quantity: 1,
detail: expect.objectContaining({
version: 4,
quantity: 1,
fulfilled_quantity: 1,
shipped_quantity: 1,
return_requested_quantity: 1,
}),
}),
expect.objectContaining({
quantity: 2,
detail: expect.objectContaining({
version: 4,
quantity: 2,
fulfilled_quantity: 2,
shipped_quantity: 2,
return_requested_quantity: 2,
}),
}),
expect.objectContaining({
quantity: 1,
detail: expect.objectContaining({
version: 4,
quantity: 1,
fulfilled_quantity: 1,
shipped_quantity: 1,
return_requested_quantity: 1,
}),
}),
],
})
)
})
})
},
})

View File

@@ -95,7 +95,7 @@ describe("Order Return - Actions", function () {
order: originalOrder,
actions,
})
}).toThrow(`Reference ID "333" not found.`)
}).toThrow(`Item ID "333" not found.`)
})
it("should validate return received", function () {

View File

@@ -2142,6 +2142,40 @@ export default class OrderModuleService<
await this.confirmOrderChange(change[0].id, sharedContext)
}
@InjectTransactionManager("baseRepository_")
async cancelFulfillment(
data: OrderTypes.CancelOrderFulfillmentDTO,
sharedContext?: Context
): Promise<void> {
const items = data.items.map((item) => {
return {
action: ChangeActionType.CANCEL_ITEM_FULFILLMENT,
internal_note: item.internal_note,
reference: data.reference,
reference_id: data.reference_id,
details: {
reference_id: item.id,
quantity: item.quantity,
metadata: item.metadata,
},
}
})
const change = await this.createOrderChange_(
{
order_id: data.order_id,
description: data.description,
internal_note: data.internal_note,
created_by: data.created_by,
metadata: data.metadata,
actions: items,
},
sharedContext
)
await this.confirmOrderChange(change[0].id, sharedContext)
}
@InjectTransactionManager("baseRepository_")
async registerShipment(
data: OrderTypes.RegisterOrderShipmentDTO,

View File

@@ -2,6 +2,7 @@ export enum ChangeActionType {
CANCEL = "CANCEL",
CANCEL_RETURN = "CANCEL_RETURN",
FULFILL_ITEM = "FULFILL_ITEM",
CANCEL_ITEM_FULFILLMENT = "CANCEL_ITEM_FULFILLMENT",
ITEM_ADD = "ITEM_ADD",
ITEM_REMOVE = "ITEM_REMOVE",
RECEIVE_DAMAGED_RETURN_ITEM = "RECEIVE_DAMAGED_RETURN_ITEM",

View File

@@ -0,0 +1,74 @@
import { MathBN, MedusaError, isDefined } from "@medusajs/utils"
import { ChangeActionType } from "../action-key"
import { OrderChangeProcessing } from "../calculate-order-change"
OrderChangeProcessing.registerActionType(
ChangeActionType.CANCEL_ITEM_FULFILLMENT,
{
operation({ action, currentOrder }) {
const existing = currentOrder.items.find(
(item) => item.id === action.details.reference_id
)!
existing.detail.fulfilled_quantity ??= 0
existing.detail.fulfilled_quantity = MathBN.sub(
existing.detail.fulfilled_quantity,
action.details.quantity
)
},
revert({ action, currentOrder }) {
const existing = currentOrder.items.find(
(item) => item.id === action.reference_id
)!
existing.detail.fulfilled_quantity = MathBN.add(
existing.detail.fulfilled_quantity,
action.details.quantity
)
},
validate({ action, currentOrder }) {
const refId = action.details?.reference_id
if (!isDefined(refId)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Reference ID is required."
)
}
const existing = currentOrder.items.find((item) => item.id === refId)
if (!existing) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Item ID "${refId}" not found.`
)
}
if (!action.details?.quantity) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Quantity to cancel item fulfillment ${refId} is required.`
)
}
if (action.details?.quantity < 1) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Quantity to cancel item ${refId} must be greater than 0.`
)
}
const greater = MathBN.gt(
action.details?.quantity,
existing.detail?.fulfilled_quantity
)
if (greater) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Cannot cancel more items than what was fulfilled for item ${refId}.`
)
}
},
}
)

View File

@@ -48,7 +48,7 @@ OrderChangeProcessing.registerActionType(ChangeActionType.CANCEL_RETURN, {
if (!existing) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Reference ID "${refId}" not found.`
`Item ID "${refId}" not found.`
)
}

View File

@@ -38,7 +38,7 @@ OrderChangeProcessing.registerActionType(ChangeActionType.FULFILL_ITEM, {
if (!existing) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Reference ID "${refId}" not found.`
`Item ID "${refId}" not found.`
)
}

View File

@@ -1,4 +1,5 @@
export * from "./cancel"
export * from "./cancel-item-fulfillment"
export * from "./cancel-return"
export * from "./fulfill-item"
export * from "./item-add"

View File

@@ -58,7 +58,7 @@ OrderChangeProcessing.registerActionType(ChangeActionType.ITEM_REMOVE, {
if (!existing) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Reference ID "${refId}" not found.`
`Item ID "${refId}" not found.`
)
}

View File

@@ -89,7 +89,7 @@ OrderChangeProcessing.registerActionType(
if (!existing) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Reference ID "${refId}" not found.`
`Item ID "${refId}" not found.`
)
}

View File

@@ -95,7 +95,7 @@ OrderChangeProcessing.registerActionType(ChangeActionType.RECEIVE_RETURN_ITEM, {
if (!existing) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Reference ID "${refId}" not found.`
`Item ID "${refId}" not found.`
)
}

View File

@@ -42,7 +42,7 @@ OrderChangeProcessing.registerActionType(ChangeActionType.RETURN_ITEM, {
if (!existing) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Reference ID "${refId}" not found.`
`Item ID "${refId}" not found.`
)
}

View File

@@ -38,7 +38,7 @@ OrderChangeProcessing.registerActionType(ChangeActionType.SHIP_ITEM, {
if (!existing) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Reference ID "${refId}" not found.`
`Item ID "${refId}" not found.`
)
}

View File

@@ -38,7 +38,7 @@ OrderChangeProcessing.registerActionType(ChangeActionType.WRITE_OFF_ITEM, {
if (!existing) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Reference ID "${refId}" not found.`
`Item ID "${refId}" not found.`
)
}