chore(order): aggregate statuses (#7497)

This commit is contained in:
Carlos R. L. Rodrigues
2024-05-29 07:05:42 -03:00
committed by GitHub
parent 8e66e10995
commit bbca54efa7
15 changed files with 577 additions and 48 deletions

View File

@@ -155,6 +155,9 @@ medusaIntegrationTestRunner({
version: 1,
display_id: 1,
payment_collections: [],
payment_status: "not_paid",
fulfillments: [],
fulfillment_status: "not_fulfilled",
summary: expect.objectContaining({
// TODO: add all summary fields
}),

View File

@@ -1,27 +1,27 @@
import React, { useEffect, useState } from "react"
import * as zod from "zod"
import { useTranslation } from "react-i18next"
import { zodResolver } from "@hookform/resolvers/zod"
import { useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { useForm, useWatch } from "react-hook-form"
import { AdminOrder } from "@medusajs/types"
import { Alert, Button, Select, toast } from "@medusajs/ui"
import { OrderDTO } from "@medusajs/types"
import { useForm, useWatch } from "react-hook-form"
import { Form } from "../../../../../components/common/form"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../../components/route-modal"
import { CreateFulfillmentSchema } from "./constants"
import { Form } from "../../../../../components/common/form"
import { OrderCreateFulfillmentItem } from "./order-create-fulfillment-item"
import { getFulfillableQuantity } from "../../../../../lib/order-item"
import { useCreateFulfillment } from "../../../../../hooks/api/fulfillment"
import { useStockLocations } from "../../../../../hooks/api/stock-locations"
import { useFulfillmentProviders } from "../../../../../hooks/api/fulfillment-providers"
import { useStockLocations } from "../../../../../hooks/api/stock-locations"
import { cleanNonValues, pick } from "../../../../../lib/common"
import { getFulfillableQuantity } from "../../../../../lib/order-item"
import { CreateFulfillmentSchema } from "./constants"
import { OrderCreateFulfillmentItem } from "./order-create-fulfillment-item"
type OrderCreateFulfillmentFormProps = {
order: OrderDTO
order: AdminOrder
}
export function OrderCreateFulfillmentForm({
@@ -43,10 +43,13 @@ export function OrderCreateFulfillmentForm({
const form = useForm<zod.infer<typeof CreateFulfillmentSchema>>({
defaultValues: {
quantity: fulfillableItems.reduce((acc, item) => {
acc[item.id] = getFulfillableQuantity(item)
return acc
}, {} as Record<string, number>),
quantity: fulfillableItems.reduce(
(acc, item) => {
acc[item.id] = getFulfillableQuantity(item)
return acc
},
{} as Record<string, number>
),
// send_notification: !order.no_notification,
},
resolver: zodResolver(CreateFulfillmentSchema),

View File

@@ -1,11 +1,11 @@
import { AdminOrder } from "@medusajs/types"
import { Container, Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { OrderNoteForm } from "./order-note-form"
import { OrderTimeline } from "./order-timeline"
import { OrderDTO } from "@medusajs/types"
type OrderActivityProps = {
order: OrderDTO
order: AdminOrder
}
export const OrderActivitySection = ({ order }: OrderActivityProps) => {

View File

@@ -5,12 +5,12 @@ import { useRef } from "react"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { AdminOrder } from "@medusajs/types"
import { useTranslation } from "react-i18next"
import { Form } from "../../../../../components/common/form"
import { OrderDTO } from "@medusajs/types"
type OrderNoteFormProps = {
order: OrderDTO
order: AdminOrder
}
const OrderNoteSchema = z.object({

View File

@@ -6,14 +6,13 @@ import { PropsWithChildren, ReactNode, useMemo, useState } from "react"
import { Link } from "react-router-dom"
import { XMarkMini } from "@medusajs/icons"
import { AdminOrder } from "@medusajs/types"
import { useTranslation } from "react-i18next"
import { Skeleton } from "../../../../../components/common/skeleton"
import { useDate } from "../../../../../hooks/use-date"
import { getStylizedAmount } from "../../../../../lib/money-amount-helpers"
import { OrderDTO } from "@medusajs/types"
type OrderTimelineProps = {
order: OrderDTO
order: AdminOrder
}
/**

View File

@@ -1,7 +1,7 @@
import { Buildings, XCircle } from "@medusajs/icons"
import {
AdminOrder,
FulfillmentDTO,
OrderDTO,
OrderLineItemDTO,
ProductVariantDTO,
} from "@medusajs/types"
@@ -27,7 +27,7 @@ import { formatProvider } from "../../../../../lib/format-provider"
import { getLocaleAmount } from "../../../../../lib/money-amount-helpers"
type OrderFulfillmentSectionProps = {
order: OrderDTO & { fulfillments: FulfillmentDTO[] }
order: AdminOrder
}
export const OrderFulfillmentSection = ({
@@ -103,11 +103,7 @@ const UnfulfilledItem = ({
)
}
const UnfulfilledItemBreakdown = ({
order,
}: {
order: OrderDTO & { fulfillments: FulfillmentDTO[] }
}) => {
const UnfulfilledItemBreakdown = ({ order }: { order: AdminOrder }) => {
const { t } = useTranslation()
// Create an array of order items that haven't been fulfilled or at least not fully fulfilled
@@ -161,7 +157,7 @@ const Fulfillment = ({
index,
}: {
fulfillment: FulfillmentDTO
order: OrderDTO
order: AdminOrder
index: number
}) => {
const { t } = useTranslation()

View File

@@ -1,5 +1,8 @@
import { Buildings, PencilSquare, ArrowUturnLeft } from "@medusajs/icons"
import { OrderDTO, OrderLineItemDTO, ReservationItemDTO } from "@medusajs/types"
import {
AdminOrder,
OrderLineItemDTO,
ReservationItemDTO,
} from "@medusajs/types"
import { Container, Copy, Heading, StatusBadge, Text } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
@@ -11,7 +14,7 @@ import {
} from "../../../../../lib/money-amount-helpers"
type OrderSummarySectionProps = {
order: OrderDTO
order: AdminOrder
}
export const OrderSummarySection = ({ order }: OrderSummarySectionProps) => {
@@ -25,7 +28,7 @@ export const OrderSummarySection = ({ order }: OrderSummarySectionProps) => {
)
}
const Header = ({ order }: { order: OrderDTO }) => {
const Header = ({ order }: { order: AdminOrder }) => {
const { t } = useTranslation()
return (
@@ -129,7 +132,7 @@ const Item = ({
)
}
const ItemBreakdown = ({ order }: { order: OrderDTO }) => {
const ItemBreakdown = ({ order }: { order: AdminOrder }) => {
// const { reservations, isError, error } = useAdminReservations({
// line_item_id: order.items.map((i) => i.id),
// })
@@ -184,7 +187,7 @@ const Cost = ({
</div>
)
const CostBreakdown = ({ order }: { order: OrderDTO }) => {
const CostBreakdown = ({ order }: { order: AdminOrder }) => {
const { t } = useTranslation()
return (
@@ -230,7 +233,7 @@ const CostBreakdown = ({ order }: { order: OrderDTO }) => {
)
}
const Total = ({ order }: { order: OrderDTO }) => {
const Total = ({ order }: { order: AdminOrder }) => {
const { t } = useTranslation()
return (

View File

@@ -0,0 +1,227 @@
import {
getLastFulfillmentStatus,
getLastPaymentStatus,
} from "../aggregate-status"
describe("Aggregate Order Status", () => {
it("should return aggregated payment collection status", () => {
expect(
getLastPaymentStatus({
payment_collections: [],
})
).toEqual("not_paid")
expect(
getLastPaymentStatus({
payment_collections: [{ status: "not_paid" }],
})
).toEqual("not_paid")
expect(
getLastPaymentStatus({
payment_collections: [{ status: "not_paid" }, { status: "awaiting" }],
})
).toEqual("awaiting")
expect(
getLastPaymentStatus({
payment_collections: [
{ status: "requires_action" },
{ status: "refunded" },
{ status: "refunded" },
{ status: "captured" },
{ status: "captured" },
{ status: "canceled" },
{ status: "authorized" },
],
})
).toEqual("requires_action")
expect(
getLastPaymentStatus({
payment_collections: [
{ status: "awaiting" },
{ status: "awaiting" },
{ status: "canceled" },
{ status: "awaiting" },
],
})
).toEqual("awaiting")
expect(
getLastPaymentStatus({
payment_collections: [
{ status: "authorized" },
{ status: "authorized" },
{ status: "canceled" },
],
})
).toEqual("authorized")
expect(
getLastPaymentStatus({
payment_collections: [
{ status: "awaiting" },
{ status: "authorized" },
{ status: "canceled" },
],
})
).toEqual("partially_authorized")
expect(
getLastPaymentStatus({
payment_collections: [
{ status: "authorized", refunded_amount: 10, amount: 10 },
{ status: "authorized", refunded_amount: 5, amount: 10 },
{ status: "canceled" },
],
})
).toEqual("partially_refunded")
expect(
getLastPaymentStatus({
payment_collections: [
{ status: "authorized", captured_amount: 10, amount: 10 },
{ status: "authorized", refunded_amount: 10, amount: 10 },
{ status: "authorized", refunded_amount: 10, amount: 10 },
{ status: "authorized" },
{ status: "canceled" },
],
})
).toEqual("partially_refunded")
expect(
getLastPaymentStatus({
payment_collections: [
{ status: "authorized", captured_amount: 10, amount: 10 },
{ status: "authorized", captured_amount: 12, amount: 12 },
{ status: "canceled" },
],
})
).toEqual("captured")
expect(
getLastPaymentStatus({
payment_collections: [
{ status: "authorized", captured_amount: 10, amount: 10 },
{ status: "authorized", captured_amount: 5, amount: 10 },
],
})
).toEqual("partially_captured")
expect(
getLastPaymentStatus({
payment_collections: [
{ status: "authorized", captured_amount: 10, amount: 10 },
{ status: "authorized", captured_amount: 10, amount: 10 },
{ status: "authorized" },
],
})
).toEqual("partially_captured")
expect(
getLastPaymentStatus({
payment_collections: [
{ status: "authorized", captured_amount: 10, amount: 10 },
{ status: "authorized", captured_amount: 12, amount: 12 },
],
})
).toEqual("captured")
expect(
getLastPaymentStatus({
payment_collections: [{ status: "canceled" }, { status: "canceled" }],
})
).toEqual("canceled")
})
it("should return aggregated fulfillment status", () => {
expect(
getLastFulfillmentStatus({
fulfillments: [],
})
).toEqual("not_fulfilled")
expect(
getLastFulfillmentStatus({
fulfillments: [{ created_at: new Date() }],
})
).toEqual("not_fulfilled")
expect(
getLastFulfillmentStatus({
fulfillments: [{ created_at: new Date() }, { packed_at: new Date() }],
})
).toEqual("partially_fulfilled")
expect(
getLastFulfillmentStatus({
fulfillments: [{ packed_at: new Date() }, { packed_at: new Date() }],
})
).toEqual("fulfilled")
expect(
getLastFulfillmentStatus({
fulfillments: [{ shipped_at: new Date() }, { packed_at: new Date() }],
})
).toEqual("partially_shipped")
expect(
getLastFulfillmentStatus({
fulfillments: [{ shipped_at: new Date() }, { shipped_at: new Date() }],
})
).toEqual("shipped")
expect(
getLastFulfillmentStatus({
fulfillments: [
{ shipped_at: new Date() },
{ delivered_at: new Date() },
],
})
).toEqual("partially_delivered")
expect(
getLastFulfillmentStatus({
fulfillments: [
{ delivered_at: new Date() },
{ delivered_at: new Date() },
],
})
).toEqual("delivered")
expect(
getLastFulfillmentStatus({
fulfillments: [
{ delivered_at: new Date() },
{ canceled_at: new Date() },
],
})
).toEqual("delivered")
expect(
getLastFulfillmentStatus({
fulfillments: [{ shipped_at: new Date() }, { canceled_at: new Date() }],
})
).toEqual("shipped")
expect(
getLastFulfillmentStatus({
fulfillments: [
{ packed_at: new Date() },
{ shipped_at: new Date() },
{ canceled_at: new Date() },
],
})
).toEqual("partially_shipped")
expect(
getLastFulfillmentStatus({
fulfillments: [
{ canceled_at: new Date() },
{ canceled_at: new Date() },
],
})
).toEqual("canceled")
})
})

View File

@@ -0,0 +1,170 @@
import { OrderDetailDTO } from "@medusajs/types"
import { MathBN } from "@medusajs/utils"
export const getLastPaymentStatus = (order: OrderDetailDTO) => {
const PaymentStatus = {
NOT_PAID: "not_paid",
AWAITING: "awaiting",
CAPTURED: "captured",
PARTIALLY_CAPTURED: "partially_captured",
PARTIALLY_REFUNDED: "partially_refunded",
REFUNDED: "refunded",
CANCELED: "canceled",
REQUIRES_ACTION: "requires_action",
AUTHORIZED: "authorized",
PARTIALLY_AUTHORIZED: "partially_authorized",
}
let paymentStatus = {}
for (const status in PaymentStatus) {
paymentStatus[PaymentStatus[status]] = 0
}
for (const paymentCollection of order.payment_collections) {
if (MathBN.gt(paymentCollection.captured_amount ?? 0, 0)) {
paymentStatus[PaymentStatus.CAPTURED] += MathBN.eq(
paymentCollection.captured_amount as number,
paymentCollection.amount
)
? 1
: 0.5
}
if (MathBN.gt(paymentCollection.refunded_amount ?? 0, 0)) {
paymentStatus[PaymentStatus.REFUNDED] += MathBN.eq(
paymentCollection.refunded_amount as number,
paymentCollection.amount
)
? 1
: 0.5
}
paymentStatus[paymentCollection.status] += 1
}
const totalPayments = order.payment_collections.length
const totalPaymentExceptCanceled =
totalPayments - paymentStatus[PaymentStatus.CANCELED]
if (paymentStatus[PaymentStatus.REQUIRES_ACTION] > 0) {
return PaymentStatus.REQUIRES_ACTION
}
if (paymentStatus[PaymentStatus.REFUNDED] > 0) {
if (paymentStatus[PaymentStatus.REFUNDED] === totalPaymentExceptCanceled) {
return PaymentStatus.REFUNDED
}
return PaymentStatus.PARTIALLY_REFUNDED
}
if (paymentStatus[PaymentStatus.CAPTURED] > 0) {
if (paymentStatus[PaymentStatus.CAPTURED] === totalPaymentExceptCanceled) {
return PaymentStatus.CAPTURED
}
return PaymentStatus.PARTIALLY_CAPTURED
}
if (paymentStatus[PaymentStatus.AUTHORIZED] > 0) {
if (
paymentStatus[PaymentStatus.AUTHORIZED] === totalPaymentExceptCanceled
) {
return PaymentStatus.AUTHORIZED
}
return PaymentStatus.PARTIALLY_AUTHORIZED
}
if (
paymentStatus[PaymentStatus.CANCELED] > 0 &&
paymentStatus[PaymentStatus.CANCELED] === totalPayments
) {
return PaymentStatus.CANCELED
}
if (paymentStatus[PaymentStatus.AWAITING] > 0) {
return PaymentStatus.AWAITING
}
return PaymentStatus.NOT_PAID
}
export const getLastFulfillmentStatus = (order: OrderDetailDTO) => {
const FulfillmentStatus = {
NOT_FULFILLED: "not_fulfilled",
PARTIALLY_FULFILLED: "partially_fulfilled",
FULFILLED: "fulfilled",
PARTIALLY_SHIPPED: "partially_shipped",
SHIPPED: "shipped",
DELIVERED: "delivered",
PARTIALLY_DELIVERED: "partially_delivered",
CANCELED: "canceled",
}
let fulfillmentStatus = {}
for (const status in FulfillmentStatus) {
fulfillmentStatus[FulfillmentStatus[status]] = 0
}
const statusMap = {
packed_at: FulfillmentStatus.FULFILLED,
shipped_at: FulfillmentStatus.SHIPPED,
delivered_at: FulfillmentStatus.DELIVERED,
canceled_at: FulfillmentStatus.CANCELED,
}
for (const fulfillmentCollection of order.fulfillments) {
for (const key in statusMap) {
if (fulfillmentCollection[key]) {
fulfillmentStatus[statusMap[key]] += 1
break
}
}
}
const totalFulfillments = order.fulfillments.length
const totalFulfillmentsExceptCanceled =
totalFulfillments - fulfillmentStatus[FulfillmentStatus.CANCELED]
if (fulfillmentStatus[FulfillmentStatus.DELIVERED] > 0) {
if (
fulfillmentStatus[FulfillmentStatus.DELIVERED] ===
totalFulfillmentsExceptCanceled
) {
return FulfillmentStatus.DELIVERED
}
return FulfillmentStatus.PARTIALLY_DELIVERED
}
if (fulfillmentStatus[FulfillmentStatus.SHIPPED] > 0) {
if (
fulfillmentStatus[FulfillmentStatus.SHIPPED] ===
totalFulfillmentsExceptCanceled
) {
return FulfillmentStatus.SHIPPED
}
return FulfillmentStatus.PARTIALLY_SHIPPED
}
if (fulfillmentStatus[FulfillmentStatus.FULFILLED] > 0) {
if (
fulfillmentStatus[FulfillmentStatus.FULFILLED] ===
totalFulfillmentsExceptCanceled
) {
return FulfillmentStatus.FULFILLED
}
return FulfillmentStatus.PARTIALLY_FULFILLED
}
if (
fulfillmentStatus[FulfillmentStatus.CANCELED] > 0 &&
fulfillmentStatus[FulfillmentStatus.CANCELED] === totalFulfillments
) {
return FulfillmentStatus.CANCELED
}
return FulfillmentStatus.NOT_FULFILLED
}

View File

@@ -0,0 +1,53 @@
import { OrderDTO, OrderDetailDTO } from "@medusajs/types"
import { deduplicate } from "@medusajs/utils"
import {
WorkflowData,
createWorkflow,
transform,
} from "@medusajs/workflows-sdk"
import { useRemoteQueryStep } from "../../common"
import {
getLastFulfillmentStatus,
getLastPaymentStatus,
} from "../utils/aggregate-status"
export const getOrderDetailWorkflowId = "get-order-detail"
export const getOrderDetailWorkflow = createWorkflow(
getOrderDetailWorkflowId,
(
input: WorkflowData<{ fields: string[]; order_id: string }>
): WorkflowData<OrderDetailDTO> => {
const fields = transform(input, ({ fields }) => {
return deduplicate([
...fields,
"id",
"status",
"version",
"payment_collections.*",
"fulfillments.*",
])
})
const order: OrderDTO = useRemoteQueryStep({
entry_point: "orders",
fields,
variables: { id: input.order_id },
list: false,
throw_if_key_not_found: true,
})
const aggregatedOrder = transform({ order }, ({ order }) => {
const order_ = order as OrderDetailDTO
order_.payment_status = getLastPaymentStatus(
order_
) as OrderDetailDTO["payment_status"]
order_.fulfillment_status = getLastFulfillmentStatus(
order_
) as OrderDetailDTO["fulfillment_status"]
return order_
})
return aggregatedOrder
}
)

View File

@@ -4,4 +4,5 @@ export * from "./create-fulfillment"
export * from "./create-orders"
export * from "./create-return"
export * from "./create-shipment"
export * from "./get-order-detail"
export * from "./update-tax-lines"

View File

@@ -221,6 +221,43 @@ export interface BaseOrderTransaction {
updated_at: Date | string
}
export interface BaseOrderFulfillment {
id: string
location_id: string
packed_at: Date | null
shipped_at: Date | null
delivered_at: Date | null
canceled_at: Date | null
data: Record<string, unknown> | null
provider_id: string
shipping_option_id: string | null
metadata: Record<string, unknown> | null
created_at: Date
updated_at: Date
}
type PaymentStatus =
| "not_paid"
| "awaiting"
| "authorized"
| "partially_authorized"
| "captured"
| "partially_captured"
| "partially_refunded"
| "refunded"
| "canceled"
| "requires_action"
type FulfillmentStatus =
| "not_fulfilled"
| "partially_fulfilled"
| "fulfilled"
| "partially_shipped"
| "shipped"
| "partially_delivered"
| "delivered"
| "canceled"
export interface BaseOrder {
id: string
version: number
@@ -234,7 +271,10 @@ export interface BaseOrder {
billing_address?: BaseOrderAddress
items: BaseOrderLineItem[] | null
shipping_methods: BaseOrderShippingMethod[] | null
payment_collection?: BasePaymentCollection
payment_collections?: BasePaymentCollection[]
payment_status: PaymentStatus
fulfillments?: BaseOrderFulfillment[]
fulfillment_status: FulfillmentStatus
transactions?: BaseOrderTransaction[]
summary: BaseOrderSummary
metadata: Record<string, unknown> | null

View File

@@ -1,5 +1,7 @@
import { BaseFilterable } from "../dal"
import { OperatorMap } from "../dal/utils"
import { FulfillmentDTO } from "../fulfillment"
import { PaymentCollectionDTO } from "../payment"
import { BigNumberRawValue, BigNumberValue } from "../totals"
export type ChangeActionType =
@@ -1102,6 +1104,35 @@ export interface OrderDTO {
raw_original_shipping_tax_total: BigNumberRawValue
}
type PaymentStatus =
| "not_paid"
| "awaiting"
| "authorized"
| "partially_authorized"
| "captured"
| "partially_captured"
| "partially_refunded"
| "refunded"
| "canceled"
| "requires_action"
type FulfillmentStatus =
| "not_fulfilled"
| "partially_fulfilled"
| "fulfilled"
| "partially_shipped"
| "shipped"
| "partially_delivered"
| "delivered"
| "canceled"
export interface OrderDetailDTO extends OrderDTO {
payment_collections: PaymentCollectionDTO[]
payment_status: PaymentStatus
fulfillments: FulfillmentDTO[]
fulfillment_status: FulfillmentStatus
}
export interface OrderChangeDTO {
/**
* The ID of the order change

View File

@@ -100,6 +100,11 @@ export interface PaymentCollectionDTO {
*/
refunded_amount?: BigNumberValue
/**
* The amount captured within the associated payments.
*/
captured_amount?: BigNumberValue
/**
* When the payment collection was completed.
*/

View File

@@ -1,3 +1,4 @@
import { getOrderDetailWorkflow } from "@medusajs/core-flows"
import {
ContainerRegistrationKeys,
remoteQueryObjectFromString,
@@ -11,18 +12,15 @@ export const GET = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse
) => {
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
const variables = { id: req.params.id }
const queryObject = remoteQueryObjectFromString({
entryPoint: "order",
variables,
fields: req.remoteQueryConfig.fields,
const worklow = getOrderDetailWorkflow(req.scope)
const { result } = await worklow.run({
input: {
fields: req.remoteQueryConfig.fields,
order_id: req.params.id,
},
})
const [order] = await remoteQuery(queryObject)
res.status(200).json({ order })
res.status(200).json({ order: result })
}
export const POST = async (