diff --git a/integration-tests/http/__tests__/fixtures/order.ts b/integration-tests/http/__tests__/fixtures/order.ts index a57e454030..78ea700233 100644 --- a/integration-tests/http/__tests__/fixtures/order.ts +++ b/integration-tests/http/__tests__/fixtures/order.ts @@ -195,5 +195,18 @@ export async function createOrderSeeder({ api, container }) { order = (await api.get(`/admin/orders/${order.id}`, adminHeaders)).data.order - return order + return { + order, + region, + salesChannel, + stockLocation, + inventoryItem, + shippingProfile, + product, + fulfillmentSets, + fulfillmentSet, + shippingOption, + cart, + paymentCollection, + } } diff --git a/integration-tests/http/__tests__/order/admin/order.spec.ts b/integration-tests/http/__tests__/order/admin/order.spec.ts new file mode 100644 index 0000000000..3390f980aa --- /dev/null +++ b/integration-tests/http/__tests__/order/admin/order.spec.ts @@ -0,0 +1,88 @@ +import { ModuleRegistrationName } from "@medusajs/utils" +import { medusaIntegrationTestRunner } from "medusa-test-utils" +import { + adminHeaders, + createAdminUser, +} from "../../../../helpers/create-admin-user" +import { setupTaxStructure } from "../../../../modules/__tests__/fixtures" +import { createOrderSeeder } from "../../fixtures/order" + +jest.setTimeout(30000) + +medusaIntegrationTestRunner({ + testSuite: ({ dbConnection, getContainer, api }) => { + let order, seeder + + beforeEach(async () => { + const container = getContainer() + + await setupTaxStructure(container.resolve(ModuleRegistrationName.TAX)) + await createAdminUser(dbConnection, adminHeaders, container) + seeder = await createOrderSeeder({ api, container }) + order = seeder.order + order = (await api.get(`/admin/orders/${order.id}`, adminHeaders)).data + .order + }) + + describe("POST /orders/:id/fulfillments/:id/mark-as-delivered", () => { + it("should mark fulfillable item as delivered", async () => { + let fulfillableItem = order.items.find( + (item) => item.detail.fulfilled_quantity < item.detail.quantity + ) + + await api.post( + `/admin/orders/${order.id}/fulfillments`, + { + location_id: seeder.stockLocation.id, + items: [ + { + id: fulfillableItem.id, + quantity: 1, + }, + ], + }, + adminHeaders + ) + + order = (await api.get(`/admin/orders/${order.id}`, adminHeaders)).data + .order + + expect(order.items[0].detail).toEqual( + expect.objectContaining({ + fulfilled_quantity: 1, + delivered_quantity: 0, + }) + ) + + await api.post( + `/admin/orders/${order.id}/fulfillments/${order.fulfillments[0].id}/mark-as-delivered`, + {}, + adminHeaders + ) + + order = (await api.get(`/admin/orders/${order.id}`, adminHeaders)).data + .order + + expect(order.items[0].detail).toEqual( + expect.objectContaining({ + fulfilled_quantity: 1, + delivered_quantity: 1, + }) + ) + + const { response } = await api + .post( + `/admin/orders/${order.id}/fulfillments/${order.fulfillments[0].id}/mark-as-delivered`, + {}, + adminHeaders + ) + .catch((e) => e) + + expect(response.data).toEqual({ + type: "not_allowed", + message: "Fulfillment has already been marked delivered", + }) + }) + }) + }, +}) diff --git a/integration-tests/http/__tests__/order/admin/rma-flows.spec.ts b/integration-tests/http/__tests__/order/admin/rma-flows.spec.ts index 32b16ca75f..8a946510b7 100644 --- a/integration-tests/http/__tests__/order/admin/rma-flows.spec.ts +++ b/integration-tests/http/__tests__/order/admin/rma-flows.spec.ts @@ -24,7 +24,8 @@ medusaIntegrationTestRunner({ await setupTaxStructure(container.resolve(Modules.TAX)) await createAdminUser(dbConnection, adminHeaders, container) - order = await createOrderSeeder({ api, container }) + const seeders = await createOrderSeeder({ api, container }) + order = seeders.order shippingProfile = ( await api.post( @@ -157,7 +158,7 @@ medusaIntegrationTestRunner({ }) describe("RMA Flows", () => { - it.only("should verify order summary at each level", async () => { + it("should verify order summary at each level", async () => { /* Case: Purchased: items: { diff --git a/integration-tests/http/__tests__/payment/admin/payment.spec.ts b/integration-tests/http/__tests__/payment/admin/payment.spec.ts index 1b3c93702b..6d412d098d 100644 --- a/integration-tests/http/__tests__/payment/admin/payment.spec.ts +++ b/integration-tests/http/__tests__/payment/admin/payment.spec.ts @@ -37,7 +37,8 @@ medusaIntegrationTestRunner({ beforeEach(async () => { container = getContainer() await createAdminUser(dbConnection, adminHeaders, container) - order = await createOrderSeeder({ api, container }) + const seeders = await createOrderSeeder({ api, container }) + order = seeders.order await api.post( `/admin/orders/${order.id}/fulfillments`, diff --git a/packages/admin/dashboard/src/hooks/api/orders.tsx b/packages/admin/dashboard/src/hooks/api/orders.tsx index 0d1c2b6563..9dd92cef17 100644 --- a/packages/admin/dashboard/src/hooks/api/orders.tsx +++ b/packages/admin/dashboard/src/hooks/api/orders.tsx @@ -1,3 +1,5 @@ +import { FetchError } from "@medusajs/js-sdk" +import { HttpTypes } from "@medusajs/types" import { QueryKey, useMutation, @@ -5,8 +7,6 @@ import { useQuery, UseQueryOptions, } from "@tanstack/react-query" -import { FetchError } from "@medusajs/js-sdk" -import { HttpTypes } from "@medusajs/types" import { sdk } from "../../lib/client" import { queryClient } from "../../lib/query-client" import { queryKeysFactory, TQueryKey } from "../../lib/query-key-factory" @@ -184,6 +184,33 @@ export const useCreateOrderShipment = ( }) } +export const useMarkOrderFulfillmentAsDelivered = ( + orderId: string, + fulfillmentId: string, + options?: UseMutationOptions< + { order: HttpTypes.AdminOrder }, + FetchError, + HttpTypes.AdminMarkOrderFulfillmentAsDelivered + > +) => { + return useMutation({ + mutationFn: (payload: HttpTypes.AdminMarkOrderFulfillmentAsDelivered) => + sdk.admin.order.markAsDelivered(orderId, fulfillmentId, payload), + onSuccess: (data: any, variables: any, context: any) => { + queryClient.invalidateQueries({ + queryKey: ordersQueryKeys.all, + }) + + queryClient.invalidateQueries({ + queryKey: ordersQueryKeys.preview(orderId), + }) + + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + export const useCancelOrder = ( orderId: string, options?: UseMutationOptions diff --git a/packages/admin/dashboard/src/i18n/translations/en.json b/packages/admin/dashboard/src/i18n/translations/en.json index 1f1c96f302..3030c48a2e 100644 --- a/packages/admin/dashboard/src/i18n/translations/en.json +++ b/packages/admin/dashboard/src/i18n/translations/en.json @@ -1065,6 +1065,7 @@ }, "fulfillment": { "cancelWarning": "You are about to cancel a fulfillment. This action cannot be undone.", + "markAsDeliveredWarning": "You are about to mark fulfillment as delivered. This action cannot be undone.", "unfulfilledItems": "Unfulfilled Items", "statusLabel": "Fulfillment status", "statusTitle": "Fulfillment Status", @@ -1076,6 +1077,7 @@ "available": "Available", "inStock": "In stock", "markAsShipped": "Mark as shipped", + "markAsDelivered": "Mark as delivered", "itemsToFulfillDesc": "Choose items and quantities to fulfill", "locationDescription": "Choose which location you want to fulfill items from.", "sendNotificationHint": "Notify customers about the created fulfillment.", @@ -1091,6 +1093,8 @@ "fulfilled": "Fulfilled", "partiallyShipped": "Partially shipped", "shipped": "Shipped", + "delivered": "Delivered", + "partiallyDelivered": "Partially delivered", "partiallyReturned": "Partially returned", "returned": "Returned", "canceled": "Canceled", @@ -1099,7 +1103,8 @@ "toast": { "created": "Fulfillment created successfully", "canceled": "Fulfillment successfully canceled", - "fulfillmentShipped": "Cannot cancel an already shipped fulfillment" + "fulfillmentShipped": "Cannot cancel an already shipped fulfillment", + "fulfillmentDelivered": "Fulfillment marked as delivered successfully" }, "trackingLabel": "Tracking", "shippingFromLabel": "Shipping from", @@ -1155,6 +1160,7 @@ "created": "Items fulfilled", "canceled": "Fulfillment canceled", "shipped": "Items shipped", + "delivered": "Items delivered", "items_one": "{{count}} item", "items_other": "{{count}} items" }, diff --git a/packages/admin/dashboard/src/lib/order-helpers.ts b/packages/admin/dashboard/src/lib/order-helpers.ts index ca1118d70a..2ca088f0de 100644 --- a/packages/admin/dashboard/src/lib/order-helpers.ts +++ b/packages/admin/dashboard/src/lib/order-helpers.ts @@ -45,6 +45,11 @@ export const getOrderFulfillmentStatus = ( "orange", ], shipped: [t("orders.fulfillment.status.shipped"), "green"], + delivered: [t("orders.fulfillment.status.delivered"), "green"], + partially_delivered: [ + t("orders.fulfillment.status.partiallyDelivered"), + "orange", + ], partially_returned: [ t("orders.fulfillment.status.partiallyReturned"), "orange", diff --git a/packages/admin/dashboard/src/routes/orders/order-detail/components/order-activity-section/order-timeline.tsx b/packages/admin/dashboard/src/routes/orders/order-detail/components/order-activity-section/order-timeline.tsx index e741b0d139..502091a814 100644 --- a/packages/admin/dashboard/src/routes/orders/order-detail/components/order-activity-section/order-timeline.tsx +++ b/packages/admin/dashboard/src/routes/orders/order-detail/components/order-activity-section/order-timeline.tsx @@ -16,6 +16,7 @@ import { import { useTranslation } from "react-i18next" import { AdminOrderLineItem } from "@medusajs/types" +import { useOrderChanges } from "../../../../../hooks/api" import { useCancelClaim, useClaims } from "../../../../../hooks/api/claims" import { useCancelExchange, @@ -25,7 +26,6 @@ import { useCancelReturn, useReturns } from "../../../../../hooks/api/returns" import { useDate } from "../../../../../hooks/use-date" import { getStylizedAmount } from "../../../../../lib/money-amount-helpers" import { getPaymentsFromOrder } from "../order-payment-section" -import { useOrderChanges } from "../../../../../hooks/api" import ActivityItems from "./activity-items" type OrderTimelineProps = { @@ -222,6 +222,16 @@ const useActivityItems = (order: AdminOrder): Activity[] => { children: , }) + if (fulfillment.delivered_at) { + items.push({ + title: t("orders.activity.events.fulfillment.delivered"), + timestamp: fulfillment.delivered_at, + children: ( + + ), + }) + } + if (fulfillment.shipped_at) { items.push({ title: t("orders.activity.events.fulfillment.shipped"), @@ -340,10 +350,10 @@ const useActivityItems = (order: AdminOrder): Activity[] => { edit.status === "requested" ? edit.requested_at : edit.status === "declined" - ? edit.declined_at - : edit.status === "canceled" - ? edit.canceled_at - : edit.created_at, + ? edit.declined_at + : edit.status === "canceled" + ? edit.canceled_at + : edit.created_at, children: isConfirmed ? ( ) : null, diff --git a/packages/admin/dashboard/src/routes/orders/order-detail/components/order-fulfillment-section/order-fulfillment-section.tsx b/packages/admin/dashboard/src/routes/orders/order-detail/components/order-fulfillment-section/order-fulfillment-section.tsx index ef45e87b8c..b9a9c50769 100644 --- a/packages/admin/dashboard/src/routes/orders/order-detail/components/order-fulfillment-section/order-fulfillment-section.tsx +++ b/packages/admin/dashboard/src/routes/orders/order-detail/components/order-fulfillment-section/order-fulfillment-section.tsx @@ -22,7 +22,10 @@ import { Link, useNavigate } from "react-router-dom" import { ActionMenu } from "../../../../../components/common/action-menu" import { Skeleton } from "../../../../../components/common/skeleton" import { Thumbnail } from "../../../../../components/common/thumbnail" -import { useCancelOrderFulfillment } from "../../../../../hooks/api/orders" +import { + useCancelOrderFulfillment, + useMarkOrderFulfillmentAsDelivered, +} from "../../../../../hooks/api/orders" import { useStockLocation } from "../../../../../hooks/api/stock-locations" import { formatProvider } from "../../../../../lib/format-provider" import { getLocaleAmount } from "../../../../../lib/money-amount-helpers" @@ -183,6 +186,10 @@ const Fulfillment = ({ statusText = "Canceled" statusColor = "red" statusTimestamp = fulfillment.canceled_at + } else if (fulfillment.delivered_at) { + statusText = "Delivered" + statusColor = "green" + statusTimestamp = fulfillment.delivered_at } else if (fulfillment.shipped_at) { statusText = "Shipped" statusColor = "green" @@ -190,8 +197,41 @@ const Fulfillment = ({ } const { mutateAsync } = useCancelOrderFulfillment(order.id, fulfillment.id) + const { mutateAsync: markAsDelivered } = useMarkOrderFulfillmentAsDelivered( + order.id, + fulfillment.id + ) - const showShippingButton = !fulfillment.canceled_at && !fulfillment.shipped_at + const showShippingButton = + !fulfillment.canceled_at && + !fulfillment.shipped_at && + !fulfillment.delivered_at + const showDeliveryButton = + !fulfillment.canceled_at && !fulfillment.delivered_at + + const handleMarkAsDelivered = async () => { + const res = await prompt({ + title: t("general.areYouSure"), + description: t("orders.fulfillment.markAsDeliveredWarning"), + confirmText: t("actions.continue"), + cancelText: t("actions.cancel"), + variant: "confirmation", + }) + + if (res) { + await markAsDelivered( + {}, + { + onSuccess: () => { + toast.success(t("orders.fulfillment.toast.fulfillmentDelivered")) + }, + onError: (e) => { + toast.error(e.message) + }, + } + ) + } + } const handleCancel = async () => { if (fulfillment.shipped_at) { @@ -343,14 +383,23 @@ const Fulfillment = ({ )} - {showShippingButton && ( -
- + + {(showShippingButton || showDeliveryButton) && ( +
+ {showDeliveryButton && ( + + )} + + {showShippingButton && ( + + )}
)} diff --git a/packages/core/core-flows/src/fulfillment/steps/update-fulfillment.ts b/packages/core/core-flows/src/fulfillment/steps/update-fulfillment.ts index 3e429878f4..2a58786931 100644 --- a/packages/core/core-flows/src/fulfillment/steps/update-fulfillment.ts +++ b/packages/core/core-flows/src/fulfillment/steps/update-fulfillment.ts @@ -13,6 +13,7 @@ export const updateFulfillmentStep = createStep( { container } ) => { const { id, ...data } = input + const service = container.resolve( Modules.FULFILLMENT ) diff --git a/packages/core/core-flows/src/fulfillment/workflows/index.ts b/packages/core/core-flows/src/fulfillment/workflows/index.ts index 9340c1aca3..fd61409d1f 100644 --- a/packages/core/core-flows/src/fulfillment/workflows/index.ts +++ b/packages/core/core-flows/src/fulfillment/workflows/index.ts @@ -9,8 +9,8 @@ export * from "./create-shipping-profiles" export * from "./delete-fulfillment-sets" export * from "./delete-service-zones" export * from "./delete-shipping-options" +export * from "./mark-fulfillment-as-delivered" export * from "./update-fulfillment" export * from "./update-service-zones" export * from "./update-shipping-options" export * from "./update-shipping-profiles" - diff --git a/packages/core/core-flows/src/fulfillment/workflows/mark-fulfillment-as-delivered.ts b/packages/core/core-flows/src/fulfillment/workflows/mark-fulfillment-as-delivered.ts new file mode 100644 index 0000000000..24befb60c8 --- /dev/null +++ b/packages/core/core-flows/src/fulfillment/workflows/mark-fulfillment-as-delivered.ts @@ -0,0 +1,67 @@ +import { FulfillmentDTO } from "@medusajs/types" +import { MedusaError } from "@medusajs/utils" +import { + StepResponse, + WorkflowData, + WorkflowResponse, + createStep, + createWorkflow, + transform, +} from "@medusajs/workflows-sdk" +import { useRemoteQueryStep } from "../../common" +import { updateFulfillmentWorkflow } from "./update-fulfillment" + +export const validateFulfillmentDeliverabilityStepId = + "validate-fulfillment-deliverability" +/** + * This step validates that if a fulfillment can be marked delivered + */ +export const validateFulfillmentDeliverabilityStep = createStep( + validateFulfillmentDeliverabilityStepId, + async (fulfillment: FulfillmentDTO) => { + if (fulfillment.canceled_at) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Cannot deliver an already canceled fulfillment" + ) + } + + if (fulfillment.delivered_at) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Fulfillment has already been marked delivered" + ) + } + + return new StepResponse(void 0) + } +) + +export const markFulfillmentAsDeliveredWorkflowId = + "mark-fulfillment-as-delivered-workflow" +/** + * This workflow marks fulfillment as delivered. + */ +export const markFulfillmentAsDeliveredWorkflow = createWorkflow( + markFulfillmentAsDeliveredWorkflowId, + ({ id }: WorkflowData<{ id: string }>) => { + const fulfillment = useRemoteQueryStep({ + entry_point: "fulfillment", + fields: ["id", "delivered_at", "canceled_at"], + variables: { id }, + throw_if_key_not_found: true, + list: false, + }) + + validateFulfillmentDeliverabilityStep(fulfillment) + + const updateInput = transform({ id }, ({ id }) => ({ + id, + delivered_at: new Date(), + })) + + return new WorkflowResponse( + updateFulfillmentWorkflow.runAsStep({ input: updateInput }) + ) + } +) diff --git a/packages/core/core-flows/src/order/steps/register-delivery.ts b/packages/core/core-flows/src/order/steps/register-delivery.ts new file mode 100644 index 0000000000..527b36a53d --- /dev/null +++ b/packages/core/core-flows/src/order/steps/register-delivery.ts @@ -0,0 +1,31 @@ +import { IOrderModuleService, RegisterOrderDeliveryDTO } from "@medusajs/types" +import { ModuleRegistrationName } from "@medusajs/utils" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +export const registerOrderDeliveryStepId = "register-order-delivery" +/** + * This step registers a delivery for an order fulfillment. + */ +export const registerOrderDeliveryStep = createStep( + registerOrderDeliveryStepId, + async (data: RegisterOrderDeliveryDTO, { container }) => { + const service = container.resolve( + ModuleRegistrationName.ORDER + ) + + await service.registerDelivery(data) + + return new StepResponse(void 0, data.order_id) + }, + async (orderId, { container }) => { + if (!orderId) { + return + } + + const service = container.resolve( + ModuleRegistrationName.ORDER + ) + + await service.revertLastVersion(orderId) + } +) diff --git a/packages/core/core-flows/src/order/workflows/index.ts b/packages/core/core-flows/src/order/workflows/index.ts index 77fab019e3..f4eefc28ae 100644 --- a/packages/core/core-flows/src/order/workflows/index.ts +++ b/packages/core/core-flows/src/order/workflows/index.ts @@ -41,6 +41,7 @@ export * from "./exchange/update-exchange-add-item" export * from "./exchange/update-exchange-shipping-method" export * from "./get-order-detail" export * from "./get-orders-list" +export * from "./mark-order-fulfillment-as-delivered" export * from "./mark-payment-collection-as-paid" export * from "./order-edit/begin-order-edit" export * from "./order-edit/cancel-begin-order-edit" diff --git a/packages/core/core-flows/src/order/workflows/mark-order-fulfillment-as-delivered.ts b/packages/core/core-flows/src/order/workflows/mark-order-fulfillment-as-delivered.ts new file mode 100644 index 0000000000..b841e34798 --- /dev/null +++ b/packages/core/core-flows/src/order/workflows/mark-order-fulfillment-as-delivered.ts @@ -0,0 +1,140 @@ +import { + FulfillmentDTO, + OrderDTO, + RegisterOrderDeliveryDTO, +} from "@medusajs/types" +import { FulfillmentEvents, Modules } from "@medusajs/utils" +import { + WorkflowData, + WorkflowResponse, + createStep, + createWorkflow, + parallelize, + transform, +} from "@medusajs/workflows-sdk" +import { emitEventStep, useRemoteQueryStep } from "../../common" +import { markFulfillmentAsDeliveredWorkflow } from "../../fulfillment" +import { registerOrderDeliveryStep } from "../steps/register-delivery" +import { + throwIfItemsDoesNotExistsInOrder, + throwIfOrderIsCancelled, +} from "../utils/order-validation" + +export const orderFulfillmentDeliverablilityValidationStepId = + "order-fulfillment-deliverability-validation" +/** + * This step validates that order & fulfillment are valid + */ +export const orderFulfillmentDeliverablilityValidationStep = createStep( + orderFulfillmentDeliverablilityValidationStepId, + async ({ + fulfillment, + order, + }: { + order: OrderDTO & { fulfillments: FulfillmentDTO[] } + fulfillment: FulfillmentDTO + }) => { + throwIfOrderIsCancelled({ order }) + + const orderFulfillment = order.fulfillments?.find( + (f) => f.id === fulfillment.id + ) + + if (!orderFulfillment) { + throw new Error( + `Fulfillment with id ${fulfillment.id} not found in the order` + ) + } + + throwIfItemsDoesNotExistsInOrder({ + order, + inputItems: order.items!.map((i) => ({ + id: i.id, + quantity: i.quantity, + })), + }) + } +) + +function prepareRegisterDeliveryData({ + fulfillment, + order, +}: { + fulfillment: FulfillmentDTO + order: OrderDTO & { fulfillments: FulfillmentDTO[] } +}): RegisterOrderDeliveryDTO { + const orderFulfillment = order.fulfillments.find( + (f) => f.id === fulfillment.id + )! + + return { + order_id: order.id, + reference: Modules.FULFILLMENT, + reference_id: orderFulfillment.id, + items: orderFulfillment.items!.map((i) => { + return { + id: i.line_item_id!, + quantity: i.quantity!, + } + }), + } +} + +export const markOrderFulfillmentAsDeliveredWorkflowId = + "mark-order-fulfillment-as-delivered-workflow" +/** + * This workflow marks a fulfillment in an order as delivered. + */ +export const markOrderFulfillmentAsDeliveredWorkflow = createWorkflow( + markOrderFulfillmentAsDeliveredWorkflowId, + (input: WorkflowData<{ orderId: string; fulfillmentId: string }>) => { + const { fulfillmentId, orderId } = input + const fulfillment = useRemoteQueryStep({ + entry_point: "fulfillment", + fields: ["id"], + variables: { id: fulfillmentId }, + throw_if_key_not_found: true, + list: false, + }) + + const order = useRemoteQueryStep({ + entry_point: "order", + fields: [ + "id", + "summary", + "currency_code", + "region_id", + "fulfillments.id", + "fulfillments.items.id", + "fulfillments.items.quantity", + "fulfillments.items.line_item_id", + "items.id", + "items.quantity", + ], + variables: { id: orderId }, + throw_if_key_not_found: true, + list: false, + }).config({ name: "order-query" }) + + orderFulfillmentDeliverablilityValidationStep({ order, fulfillment }) + + const deliveryData = transform( + { order, fulfillment }, + prepareRegisterDeliveryData + ) + + const [deliveredFulfillment] = parallelize( + markFulfillmentAsDeliveredWorkflow.runAsStep({ + input: { id: fulfillment.id }, + }), + registerOrderDeliveryStep(deliveryData) + ) + + emitEventStep({ + eventName: FulfillmentEvents.DELIVERY_CREATED, + data: { id: deliveredFulfillment.id }, + }) + + return new WorkflowResponse(void 0) + } +) diff --git a/packages/core/js-sdk/src/admin/order.ts b/packages/core/js-sdk/src/admin/order.ts index 7340871416..8f3286fec2 100644 --- a/packages/core/js-sdk/src/admin/order.ts +++ b/packages/core/js-sdk/src/admin/order.ts @@ -112,6 +112,24 @@ export class Order { ) } + async markAsDelivered( + id: string, + fulfillmentId: string, + body: HttpTypes.AdminMarkOrderFulfillmentAsDelivered, + query?: SelectParams, + headers?: ClientHeaders + ) { + return await this.client.fetch<{ order: HttpTypes.AdminOrder }>( + `/admin/orders/${id}/fulfillments/${fulfillmentId}/mark-as-delivered`, + { + method: "POST", + headers, + body, + query, + } + ) + } + async listChanges( id: string, queryParams?: FindParams & HttpTypes.AdminOrderChangesFilters, diff --git a/packages/core/types/src/http/order/admin/payload.ts b/packages/core/types/src/http/order/admin/payload.ts index d044b5d1c3..3ce1d41cb9 100644 --- a/packages/core/types/src/http/order/admin/payload.ts +++ b/packages/core/types/src/http/order/admin/payload.ts @@ -19,3 +19,5 @@ export interface AdminCreateOrderShipment { export interface AdminCancelOrderFulfillment { no_notification?: boolean } + +export interface AdminMarkOrderFulfillmentAsDelivered {} diff --git a/packages/core/types/src/http/order/common.ts b/packages/core/types/src/http/order/common.ts index 498b4c4b7b..161f36b710 100644 --- a/packages/core/types/src/http/order/common.ts +++ b/packages/core/types/src/http/order/common.ts @@ -165,6 +165,7 @@ export interface BaseOrderItemDetail { item: BaseOrderLineItem quantity: number fulfilled_quantity: number + delivered_quantity: number shipped_quantity: number return_requested_quantity: number return_received_quantity: number diff --git a/packages/core/types/src/order/common.ts b/packages/core/types/src/order/common.ts index e07730425d..17af49c3a6 100644 --- a/packages/core/types/src/order/common.ts +++ b/packages/core/types/src/order/common.ts @@ -11,6 +11,7 @@ import { ClaimReason } from "./mutations" export type ChangeActionType = | "CANCEL_RETURN_ITEM" | "FULFILL_ITEM" + | "DELIVER_ITEM" | "CANCEL_ITEM_FULFILLMENT" | "ITEM_ADD" | "ITEM_REMOVE" @@ -899,6 +900,16 @@ export interface OrderItemDTO { */ raw_fulfilled_quantity: BigNumberRawValue + /** + * The delivered quantity of the order line item. + */ + delivered_quantity: number + + /** + * The raw delivered quantity of the order line item. + */ + raw_delivered_quantity: BigNumberRawValue + /** * The shipped quantity of the order line item. */ diff --git a/packages/core/types/src/order/mutations.ts b/packages/core/types/src/order/mutations.ts index bf2f40da20..0308e3ec50 100644 --- a/packages/core/types/src/order/mutations.ts +++ b/packages/core/types/src/order/mutations.ts @@ -1468,6 +1468,22 @@ export interface RegisterOrderShipmentDTO extends BaseOrderBundledActionsDTO { no_notification?: boolean } +/** + * The details to register a delivery of an order, return, exchange, + * or claim. + */ +export interface RegisterOrderDeliveryDTO extends BaseOrderBundledActionsDTO { + /** + * The items of the delivery. + */ + items?: BaseOrderBundledItemActionsDTO[] + + /** + * Whether the customer should receive notifications about the delivery. + */ + no_notification?: boolean +} + /** * The return to be created. */ diff --git a/packages/core/types/src/order/service.ts b/packages/core/types/src/order/service.ts index 63725a2f39..99a00d8757 100644 --- a/packages/core/types/src/order/service.ts +++ b/packages/core/types/src/order/service.ts @@ -67,6 +67,7 @@ import { CreateOrderTransactionDTO, DeclineOrderChangeDTO, ReceiveOrderReturnDTO, + RegisterOrderDeliveryDTO, RegisterOrderFulfillmentDTO, RegisterOrderShipmentDTO, UpdateOrderAddressDTO, @@ -4591,6 +4592,29 @@ export interface IOrderModuleService extends IModuleService { sharedContext?: Context ): Promise + /** + * This method registers a delivery for an order's fulfillment + * + * @param {RegisterOrderDeliveryDTO} data - The ordes's delivery data. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise} Resolves when the delivery is registered successfully. + * + * @example + * await orderModuleService.registerDelivery({ + * order_id: "123", + * items: [ + * { + * id: "321", + * quantity: 1 + * } + * ] + * }) + */ + registerDelivery( + data: RegisterOrderDeliveryDTO, + sharedContext?: Context + ): Promise + /** * This method creates a return. * diff --git a/packages/core/types/src/workflow/order/create-shipment.ts b/packages/core/types/src/workflow/order/create-shipment.ts index 024e107489..966deb04f8 100644 --- a/packages/core/types/src/workflow/order/create-shipment.ts +++ b/packages/core/types/src/workflow/order/create-shipment.ts @@ -16,3 +16,13 @@ export interface CreateOrderShipmentWorkflowInput { no_notification?: boolean metadata?: MetadataType } + +interface CreateOrderDeliveryItem { + id: string + quantity: BigNumberInput +} + +export interface CreateOrderDeliveryWorkflowInput { + order_id: string + fulfillment_id: string +} diff --git a/packages/core/utils/src/fulfillment/events.ts b/packages/core/utils/src/fulfillment/events.ts index 1f85fbee16..f4e61b3618 100644 --- a/packages/core/utils/src/fulfillment/events.ts +++ b/packages/core/utils/src/fulfillment/events.ts @@ -30,4 +30,5 @@ const eventBaseNames: [ export const FulfillmentEvents = { ...buildEventNamesFromEntityName(eventBaseNames, Modules.FULFILLMENT), SHIPMENT_CREATED: "shipment.created", + DELIVERY_CREATED: "delivery.created", } diff --git a/packages/core/utils/src/order/order-change-action.ts b/packages/core/utils/src/order/order-change-action.ts index 006e70e468..57f43347a1 100644 --- a/packages/core/utils/src/order/order-change-action.ts +++ b/packages/core/utils/src/order/order-change-action.ts @@ -1,5 +1,6 @@ export enum ChangeActionType { FULFILL_ITEM = "FULFILL_ITEM", + DELIVER_ITEM = "DELIVER_ITEM", CANCEL_ITEM_FULFILLMENT = "CANCEL_ITEM_FULFILLMENT", ITEM_ADD = "ITEM_ADD", ITEM_REMOVE = "ITEM_REMOVE", diff --git a/packages/core/utils/src/totals/line-item/index.ts b/packages/core/utils/src/totals/line-item/index.ts index 6dbdab899a..dbe97099e5 100644 --- a/packages/core/utils/src/totals/line-item/index.ts +++ b/packages/core/utils/src/totals/line-item/index.ts @@ -19,6 +19,7 @@ export interface GetItemTotalInput { adjustments?: Pick[] detail?: { fulfilled_quantity: BigNumber + delivered_quantity: BigNumber shipped_quantity: BigNumber return_requested_quantity: BigNumber return_received_quantity: BigNumber diff --git a/packages/medusa/src/api/admin/orders/[id]/fulfillments/[fulfillment_id]/mark-as-delivered/route.ts b/packages/medusa/src/api/admin/orders/[id]/fulfillments/[fulfillment_id]/mark-as-delivered/route.ts new file mode 100644 index 0000000000..38775f1648 --- /dev/null +++ b/packages/medusa/src/api/admin/orders/[id]/fulfillments/[fulfillment_id]/mark-as-delivered/route.ts @@ -0,0 +1,28 @@ +import { markOrderFulfillmentAsDeliveredWorkflow } from "@medusajs/core-flows" +import { HttpTypes } from "@medusajs/types" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "../../../../../../../types/routing" +import { refetchEntity } from "../../../../../../utils/refetch-entity" +import { AdminMarkOrderFulfillmentDeliveredType } from "../../../../validators" + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const { id: orderId, fulfillment_id: fulfillmentId } = req.params + + await markOrderFulfillmentAsDeliveredWorkflow(req.scope).run({ + input: { orderId, fulfillmentId }, + }) + + const order = await refetchEntity( + "order", + orderId, + req.scope, + req.remoteQueryConfig.fields + ) + + res.status(200).json({ order }) +} diff --git a/packages/medusa/src/api/admin/orders/middlewares.ts b/packages/medusa/src/api/admin/orders/middlewares.ts index e9c52c2b12..8516de3a82 100644 --- a/packages/medusa/src/api/admin/orders/middlewares.ts +++ b/packages/medusa/src/api/admin/orders/middlewares.ts @@ -6,6 +6,7 @@ import { AdminCompleteOrder, AdminGetOrdersOrderParams, AdminGetOrdersParams, + AdminMarkOrderFulfillmentDelivered, AdminOrderCancelFulfillment, AdminOrderChanges, AdminOrderCreateFulfillment, @@ -119,4 +120,15 @@ export const adminOrderRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, + { + method: ["POST"], + matcher: "/admin/orders/:id/fulfillments/:fulfillment_id/mark-as-delivered", + middlewares: [ + validateAndTransformBody(AdminMarkOrderFulfillmentDelivered), + validateAndTransformQuery( + AdminGetOrdersOrderParams, + QueryConfig.retrieveTransformQueryConfig + ), + ], + }, ] diff --git a/packages/medusa/src/api/admin/orders/validators.ts b/packages/medusa/src/api/admin/orders/validators.ts index 9f466bf47c..0f8e3ccfe7 100644 --- a/packages/medusa/src/api/admin/orders/validators.ts +++ b/packages/medusa/src/api/admin/orders/validators.ts @@ -101,3 +101,8 @@ export const AdminOrderChanges = z.object({ deleted_at: createOperatorMap().optional(), }) export type AdminOrderChangesType = z.infer + +export type AdminMarkOrderFulfillmentDeliveredType = z.infer< + typeof AdminMarkOrderFulfillmentDelivered +> +export const AdminMarkOrderFulfillmentDelivered = z.object({}) diff --git a/packages/modules/inventory-next/src/services/inventory-module.ts b/packages/modules/inventory-next/src/services/inventory-module.ts index adce53b266..7a56e6c013 100644 --- a/packages/modules/inventory-next/src/services/inventory-module.ts +++ b/packages/modules/inventory-next/src/services/inventory-module.ts @@ -1043,7 +1043,7 @@ export default class InventoryModuleService locationId: string, @MedusaContext() context: Context = {} ): Promise { - const inventoryLevel = await this.listInventoryLevels( + const [inventoryLevel] = await this.listInventoryLevels( { inventory_item_id: inventoryItemId, location_id: locationId }, { take: null }, context @@ -1056,7 +1056,7 @@ export default class InventoryModuleService ) } - return inventoryLevel[0] + return inventoryLevel } /** diff --git a/packages/modules/order/integration-tests/__tests__/order-edit.ts b/packages/modules/order/integration-tests/__tests__/order-edit.ts index 1366c953d2..663aa6d559 100644 --- a/packages/modules/order/integration-tests/__tests__/order-edit.ts +++ b/packages/modules/order/integration-tests/__tests__/order-edit.ts @@ -397,6 +397,13 @@ moduleIntegrationTestRunner({ quantity: 4, }, }, + { + action: ChangeActionType.DELIVER_ITEM, + details: { + reference_id: createdOrder.items![1].id, + quantity: 1, + }, + }, ], }) @@ -421,6 +428,7 @@ moduleIntegrationTestRunner({ expect.objectContaining({ quantity: 4, fulfilled_quantity: 1, + delivered_quantity: 1, }) ) @@ -499,6 +507,30 @@ moduleIntegrationTestRunner({ fulfilled_quantity: 2, }) ) + + const orderChange5 = await service.createOrderChange({ + order_id: createdOrder.id, + actions: [ + { + action: ChangeActionType.DELIVER_ITEM, + details: { + reference_id: createdOrder.items![1].id, + quantity: 5, + }, + }, + ], + }) + + await expect( + service.confirmOrderChange({ + id: orderChange5.id, + }) + ).rejects.toThrow( + `Cannot deliver more items than what was fulfilled for item ${ + createdOrder.items![1].id + }` + ) + await service.deleteOrderChanges([orderChange5.id]) }) it("should create an order change, add actions to it, confirm the changes, revert all the changes and restore the changes again.", async function () { diff --git a/packages/modules/order/src/migrations/.snapshot-medusa-order.json b/packages/modules/order/src/migrations/.snapshot-medusa-order.json index c9eec4b51c..0aa7c0dccc 100644 --- a/packages/modules/order/src/migrations/.snapshot-medusa-order.json +++ b/packages/modules/order/src/migrations/.snapshot-medusa-order.json @@ -869,6 +869,25 @@ "nullable": false, "mappedType": "json" }, + "delivered_quantity": { + "name": "delivered_quantity", + "type": "numeric", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "0", + "mappedType": "decimal" + }, + "raw_delivered_quantity": { + "name": "raw_delivered_quantity", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "json" + }, "shipped_quantity": { "name": "shipped_quantity", "type": "numeric", diff --git a/packages/modules/order/src/migrations/Migration20240913092514.ts b/packages/modules/order/src/migrations/Migration20240913092514.ts new file mode 100644 index 0000000000..c661500b51 --- /dev/null +++ b/packages/modules/order/src/migrations/Migration20240913092514.ts @@ -0,0 +1,26 @@ +import { Migration } from "@mikro-orm/migrations" + +export class Migration20240913092514 extends Migration { + async up(): Promise { + this.addSql( + 'alter table if exists "order_item" add column if not exists "delivered_quantity" numeric not null default 0, add column if not exists "raw_delivered_quantity" jsonb;' + ) + + this.addSql( + `UPDATE "order_item" SET raw_delivered_quantity = '{"value": "0", "precision": 20}'::jsonb;` + ) + + this.addSql( + 'ALTER TABLE IF EXISTS "order_item" ALTER COLUMN "raw_delivered_quantity" SET NOT NULL;' + ) + } + + async down(): Promise { + this.addSql( + 'alter table if exists "order_item" drop column if exists "delivered_quantity";' + ) + this.addSql( + 'alter table if exists "order_item" drop column if exists "raw_delivered_quantity";' + ) + } +} diff --git a/packages/modules/order/src/models/order-item.ts b/packages/modules/order/src/models/order-item.ts index 98009db9b2..778b180453 100644 --- a/packages/modules/order/src/models/order-item.ts +++ b/packages/modules/order/src/models/order-item.ts @@ -96,6 +96,12 @@ export default class OrderItem { @Property({ columnType: "jsonb" }) raw_fulfilled_quantity: BigNumberRawValue + @MikroOrmBigNumberProperty() + delivered_quantity: BigNumber | number = 0 + + @Property({ columnType: "jsonb" }) + raw_delivered_quantity: BigNumberRawValue + @MikroOrmBigNumberProperty() shipped_quantity: BigNumber | number = 0 diff --git a/packages/modules/order/src/services/actions/index.ts b/packages/modules/order/src/services/actions/index.ts index b721ab9cf6..6c1526da4b 100644 --- a/packages/modules/order/src/services/actions/index.ts +++ b/packages/modules/order/src/services/actions/index.ts @@ -6,5 +6,6 @@ export * from "./create-claim" export * from "./create-exchange" export * from "./create-return" export * from "./receive-return" +export * from "./register-delivery" export * from "./register-fulfillment" export * from "./register-shipment" diff --git a/packages/modules/order/src/services/actions/register-delivery.ts b/packages/modules/order/src/services/actions/register-delivery.ts new file mode 100644 index 0000000000..3c2d06a06e --- /dev/null +++ b/packages/modules/order/src/services/actions/register-delivery.ts @@ -0,0 +1,36 @@ +import { Context, OrderTypes } from "@medusajs/types" +import { ChangeActionType } from "@medusajs/utils" + +export async function registerDelivery( + this: any, + data: OrderTypes.RegisterOrderDeliveryDTO, + sharedContext?: Context +): Promise { + const items = data.items?.map((item) => { + return { + action: ChangeActionType.DELIVER_ITEM, + 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) +} diff --git a/packages/modules/order/src/services/order-module-service.ts b/packages/modules/order/src/services/order-module-service.ts index 6e5b62b9ab..530a8eadca 100644 --- a/packages/modules/order/src/services/order-module-service.ts +++ b/packages/modules/order/src/services/order-module-service.ts @@ -3295,4 +3295,12 @@ export default class OrderModuleService< ): Promise { return await BundledActions.registerShipment.bind(this)(data, sharedContext) } + + @InjectTransactionManager("baseRepository_") + async registerDelivery( + data: OrderTypes.RegisterOrderDeliveryDTO, + @MedusaContext() sharedContext?: Context + ): Promise { + return await BundledActions.registerDelivery.bind(this)(data, sharedContext) + } } diff --git a/packages/modules/order/src/types/utils/index.ts b/packages/modules/order/src/types/utils/index.ts index 7b82fccb9a..50e26a3b4b 100644 --- a/packages/modules/order/src/types/utils/index.ts +++ b/packages/modules/order/src/types/utils/index.ts @@ -24,6 +24,7 @@ export type VirtualOrder = { quantity: BigNumberInput shipped_quantity: BigNumberInput fulfilled_quantity: BigNumberInput + delivered_quantity: BigNumberInput return_requested_quantity: BigNumberInput return_received_quantity: BigNumberInput return_dismissed_quantity: BigNumberInput diff --git a/packages/modules/order/src/utils/actions/deliver-item.ts b/packages/modules/order/src/utils/actions/deliver-item.ts new file mode 100644 index 0000000000..53469dea5e --- /dev/null +++ b/packages/modules/order/src/utils/actions/deliver-item.ts @@ -0,0 +1,67 @@ +import { ChangeActionType, MathBN, MedusaError } from "@medusajs/utils" +import { OrderChangeProcessing } from "../calculate-order-change" +import { setActionReference } from "../set-action-reference" + +OrderChangeProcessing.registerActionType(ChangeActionType.DELIVER_ITEM, { + operation({ action, currentOrder, options }) { + const item = currentOrder.items.find( + (item) => item.id === action.details.reference_id + )! + + item.detail.delivered_quantity ??= 0 + + item.detail.delivered_quantity = MathBN.add( + item.detail.delivered_quantity, + action.details.quantity + ) + + setActionReference(item, action, options) + }, + validate({ action, currentOrder }) { + const refId = action.details?.reference_id + + if (refId == null) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Reference ID is required." + ) + } + + const item = currentOrder.items.find((item) => item.id === refId) + + if (!item) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Item ID "${refId}" not found.` + ) + } + + const totalDeliverable = MathBN.convert(item.quantity) + const totalDelivered = MathBN.convert(item.detail?.delivered_quantity) + const newDelivered = MathBN.convert(action.details?.quantity ?? 0) + const newTotalDelivered = MathBN.sum(totalDelivered, newDelivered) + + const totalFulfilled = MathBN.convert(item.detail?.fulfilled_quantity) + + if (MathBN.lte(newDelivered, 0)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Quantity of item ${refId} must be greater than 0.` + ) + } + + if (MathBN.gt(newTotalDelivered, totalFulfilled)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Cannot deliver more items than what was fulfilled for item ${refId}.` + ) + } + + if (MathBN.gt(newTotalDelivered, totalDeliverable)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Cannot deliver more items than what was ordered for item ${refId}.` + ) + } + }, +}) diff --git a/packages/modules/order/src/utils/actions/index.ts b/packages/modules/order/src/utils/actions/index.ts index fe1ea6d71d..39b127cb6e 100644 --- a/packages/modules/order/src/utils/actions/index.ts +++ b/packages/modules/order/src/utils/actions/index.ts @@ -1,5 +1,6 @@ export * from "./cancel-item-fulfillment" export * from "./cancel-return" +export * from "./deliver-item" export * from "./fulfill-item" export * from "./item-add" export * from "./item-remove" diff --git a/packages/modules/order/src/utils/apply-order-changes.ts b/packages/modules/order/src/utils/apply-order-changes.ts index da6f862c39..0899d91c37 100644 --- a/packages/modules/order/src/utils/apply-order-changes.ts +++ b/packages/modules/order/src/utils/apply-order-changes.ts @@ -58,6 +58,7 @@ export function applyChangesToOrder( version, quantity: orderItem.quantity, fulfilled_quantity: orderItem.fulfilled_quantity ?? 0, + delivered_quantity: orderItem.delivered_quantity ?? 0, shipped_quantity: orderItem.shipped_quantity ?? 0, return_requested_quantity: orderItem.return_requested_quantity ?? 0, return_received_quantity: orderItem.return_received_quantity ?? 0, diff --git a/packages/modules/order/src/utils/calculate-order-change.ts b/packages/modules/order/src/utils/calculate-order-change.ts index 8dd3b36cc7..7c41890ad5 100644 --- a/packages/modules/order/src/utils/calculate-order-change.ts +++ b/packages/modules/order/src/utils/calculate-order-change.ts @@ -148,6 +148,7 @@ export class OrderChangeProcessing { isReplay = false ): BigNumberInput | void { const definedType = OrderChangeProcessing.typeDefinition[action.action] + if (!isPresent(definedType)) { throw new Error(`Action type ${action.action} is not defined`) }