feat(core-flows,medusa,utils,types): adds delivered_quantity to order (#9130)

what:

- adds delivered_quantity to order


https://github.com/user-attachments/assets/709b1727-08ed-4a88-ae29-38f13540e301
This commit is contained in:
Riqwan Thamir
2024-09-16 11:59:01 +02:00
committed by GitHub
parent 950cf9af79
commit 3e97a64b21
41 changed files with 794 additions and 25 deletions

View File

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

View File

@@ -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",
})
})
})
},
})

View File

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

View File

@@ -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`,

View File

@@ -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<any, FetchError, any>

View File

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

View File

@@ -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",

View File

@@ -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: <FulfillmentCreatedBody fulfillment={fulfillment} />,
})
if (fulfillment.delivered_at) {
items.push({
title: t("orders.activity.events.fulfillment.delivered"),
timestamp: fulfillment.delivered_at,
children: (
<FulfillmentCreatedBody fulfillment={fulfillment} />
),
})
}
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 ? (
<OrderEditBody edit={edit} itemsMap={itemsMap} />
) : null,

View File

@@ -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 = ({
)}
</div>
</div>
{showShippingButton && (
<div className="bg-ui-bg-subtle flex items-center justify-end rounded-b-xl px-4 py-4">
<Button
onClick={() => navigate(`./${fulfillment.id}/create-shipment`)}
variant="secondary"
>
{t("orders.fulfillment.markAsShipped")}
</Button>
{(showShippingButton || showDeliveryButton) && (
<div className="bg-ui-bg-subtle flex items-center justify-end rounded-b-xl px-4 py-4 gap-x-2">
{showDeliveryButton && (
<Button onClick={handleMarkAsDelivered} variant="secondary">
{t("orders.fulfillment.markAsDelivered")}
</Button>
)}
{showShippingButton && (
<Button
onClick={() => navigate(`./${fulfillment.id}/create-shipment`)}
variant="secondary"
>
{t("orders.fulfillment.markAsShipped")}
</Button>
)}
</div>
)}
</Container>

View File

@@ -13,6 +13,7 @@ export const updateFulfillmentStep = createStep(
{ container }
) => {
const { id, ...data } = input
const service = container.resolve<IFulfillmentModuleService>(
Modules.FULFILLMENT
)

View File

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

View File

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

View File

@@ -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<IOrderModuleService>(
ModuleRegistrationName.ORDER
)
await service.registerDelivery(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

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

View File

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

View File

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

View File

@@ -19,3 +19,5 @@ export interface AdminCreateOrderShipment {
export interface AdminCancelOrderFulfillment {
no_notification?: boolean
}
export interface AdminMarkOrderFulfillmentAsDelivered {}

View File

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

View File

@@ -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.
*/

View File

@@ -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.
*/

View File

@@ -67,6 +67,7 @@ import {
CreateOrderTransactionDTO,
DeclineOrderChangeDTO,
ReceiveOrderReturnDTO,
RegisterOrderDeliveryDTO,
RegisterOrderFulfillmentDTO,
RegisterOrderShipmentDTO,
UpdateOrderAddressDTO,
@@ -4591,6 +4592,29 @@ export interface IOrderModuleService extends IModuleService {
sharedContext?: Context
): Promise<void>
/**
* 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<void>} 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<void>
/**
* This method creates a return.
*

View File

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

View File

@@ -30,4 +30,5 @@ const eventBaseNames: [
export const FulfillmentEvents = {
...buildEventNamesFromEntityName(eventBaseNames, Modules.FULFILLMENT),
SHIPMENT_CREATED: "shipment.created",
DELIVERY_CREATED: "delivery.created",
}

View File

@@ -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",

View File

@@ -19,6 +19,7 @@ export interface GetItemTotalInput {
adjustments?: Pick<AdjustmentLineDTO, "amount">[]
detail?: {
fulfilled_quantity: BigNumber
delivered_quantity: BigNumber
shipped_quantity: BigNumber
return_requested_quantity: BigNumber
return_received_quantity: BigNumber

View File

@@ -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<AdminMarkOrderFulfillmentDeliveredType>,
res: MedusaResponse<HttpTypes.AdminOrderResponse>
) => {
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 })
}

View File

@@ -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
),
],
},
]

View File

@@ -101,3 +101,8 @@ export const AdminOrderChanges = z.object({
deleted_at: createOperatorMap().optional(),
})
export type AdminOrderChangesType = z.infer<typeof AdminOrderChanges>
export type AdminMarkOrderFulfillmentDeliveredType = z.infer<
typeof AdminMarkOrderFulfillmentDelivered
>
export const AdminMarkOrderFulfillmentDelivered = z.object({})

View File

@@ -1043,7 +1043,7 @@ export default class InventoryModuleService
locationId: string,
@MedusaContext() context: Context = {}
): Promise<InventoryTypes.InventoryLevelDTO> {
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
}
/**

View File

@@ -397,6 +397,13 @@ moduleIntegrationTestRunner<IOrderModuleService>({
quantity: 4,
},
},
{
action: ChangeActionType.DELIVER_ITEM,
details: {
reference_id: createdOrder.items![1].id,
quantity: 1,
},
},
],
})
@@ -421,6 +428,7 @@ moduleIntegrationTestRunner<IOrderModuleService>({
expect.objectContaining({
quantity: 4,
fulfilled_quantity: 1,
delivered_quantity: 1,
})
)
@@ -499,6 +507,30 @@ moduleIntegrationTestRunner<IOrderModuleService>({
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 () {

View File

@@ -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",

View File

@@ -0,0 +1,26 @@
import { Migration } from "@mikro-orm/migrations"
export class Migration20240913092514 extends Migration {
async up(): Promise<void> {
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<void> {
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";'
)
}
}

View File

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

View File

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

View File

@@ -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<void> {
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)
}

View File

@@ -3295,4 +3295,12 @@ export default class OrderModuleService<
): Promise<void> {
return await BundledActions.registerShipment.bind(this)(data, sharedContext)
}
@InjectTransactionManager("baseRepository_")
async registerDelivery(
data: OrderTypes.RegisterOrderDeliveryDTO,
@MedusaContext() sharedContext?: Context
): Promise<void> {
return await BundledActions.registerDelivery.bind(this)(data, sharedContext)
}
}

View File

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

View File

@@ -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}.`
)
}
},
})

View File

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

View File

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

View File

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