feat(core-flows,dashboard,types,fulfillment,medusa): uses requires shipping throughout lifecycle (#9170)

what:

- uses requires shipping throughout lifecycle

https://github.com/user-attachments/assets/d5ba89d3-5ea0-49c4-b2d5-490c4764933e
This commit is contained in:
Riqwan Thamir authored and GitHub committed 2024-09-24 08:26:22 +00:00
1 parent e54b339324
commit 69f6645716
22 files changed
+432 -50

No files matched your search

@@ -1,10 +1,30 @@
import {
AdminInventoryItem,
AdminProduct,
AdminStockLocation,
MedusaContainer,
} from "@medusajs/types"
import { import {
adminHeaders, adminHeaders,
generatePublishableKey, generatePublishableKey,
generateStoreHeaders, generateStoreHeaders,
} from "../../../helpers/create-admin-user" } from "../../../helpers/create-admin-user"
export async function createOrderSeeder({ api, container }) { export async function createOrderSeeder({
api,
container,
productOverride,
additionalProducts,
stockChannelOverride,
inventoryItemOverride,
}: {
api: any
container: MedusaContainer
productOverride?: AdminProduct
stockChannelOverride?: AdminStockLocation
additionalProducts?: AdminProduct[]
inventoryItemOverride?: AdminInventoryItem
}) {
const publishableKey = await generatePublishableKey(container) const publishableKey = await generatePublishableKey(container)
const storeHeaders = generateStoreHeaders({ publishableKey }) const storeHeaders = generateStoreHeaders({ publishableKey })
@@ -24,7 +44,9 @@ export async function createOrderSeeder({ api, container }) {
) )
).data.sales_channel ).data.sales_channel
const stockLocation = ( const stockLocation =
stockChannelOverride ??
(
await api.post( await api.post(
`/admin/stock-locations`, `/admin/stock-locations`,
{ name: "test location" }, { name: "test location" },
@@ -32,7 +54,9 @@ export async function createOrderSeeder({ api, container }) {
) )
).data.stock_location ).data.stock_location
const inventoryItem = ( const inventoryItem =
inventoryItemOverride ??
(
await api.post( await api.post(
`/admin/inventory-items`, `/admin/inventory-items`,
{ sku: "test-variant" }, { sku: "test-variant" },
@@ -63,7 +87,9 @@ export async function createOrderSeeder({ api, container }) {
) )
).data.shipping_profile ).data.shipping_profile
const product = ( const product =
productOverride ??
(
await api.post( await api.post(
"/admin/products", "/admin/products",
{ {
@@ -167,7 +193,13 @@ export async function createOrderSeeder({ api, container }) {
postal_code: "94016", postal_code: "94016",
}, },
sales_channel_id: salesChannel.id, sales_channel_id: salesChannel.id,
items: [{ quantity: 1, variant_id: product.variants[0].id }], items: [
{ quantity: 1, variant_id: product.variants[0].id },
...(additionalProducts || []).map((p) => ({
quantity: 1,
variant_id: p.variants?.[0]?.id,
})),
],
}, },
storeHeaders storeHeaders
) )
@@ -18,13 +18,192 @@ medusaIntegrationTestRunner({
await setupTaxStructure(container.resolve(ModuleRegistrationName.TAX)) await setupTaxStructure(container.resolve(ModuleRegistrationName.TAX))
await createAdminUser(dbConnection, adminHeaders, container) await createAdminUser(dbConnection, adminHeaders, container)
seeder = await createOrderSeeder({ api, container }) })
describe("POST /orders/:id/fulfillments", () => {
beforeEach(async () => {
const stockChannelOverride = (
await api.post(
`/admin/stock-locations`,
{ name: "test location" },
adminHeaders
)
).data.stock_location
const inventoryItemOverride = (
await api.post(
`/admin/inventory-items`,
{ sku: "test-variant", requires_shipping: true },
adminHeaders
)
).data.inventory_item
const productOverride = (
await api.post(
"/admin/products",
{
title: `Test fixture`,
options: [
{ title: "size", values: ["large", "small"] },
{ title: "color", values: ["green"] },
],
variants: [
{
title: "Test variant",
sku: "test-variant",
inventory_items: [
{
inventory_item_id: inventoryItemOverride.id,
required_quantity: 1,
},
],
prices: [
{
currency_code: "usd",
amount: 100,
},
],
options: {
size: "large",
color: "green",
},
},
],
},
adminHeaders
)
).data.product
const inventoryItemOverride2 = (
await api.post(
`/admin/inventory-items`,
{ sku: "test-variant-2", requires_shipping: false },
adminHeaders
)
).data.inventory_item
await api.post(
`/admin/inventory-items/${inventoryItemOverride2.id}/location-levels`,
{
location_id: stockChannelOverride.id,
stocked_quantity: 10,
},
adminHeaders
)
const productOverride2 = (
await api.post(
"/admin/products",
{
title: `Test fixture 2`,
options: [
{ title: "size", values: ["large", "small"] },
{ title: "color", values: ["green"] },
],
variants: [
{
title: "Test variant 2",
sku: "test-variant-2",
inventory_items: [
{
inventory_item_id: inventoryItemOverride2.id,
required_quantity: 1,
},
],
prices: [
{
currency_code: "usd",
amount: 100,
},
],
options: {
size: "large",
color: "green",
},
},
],
},
adminHeaders
)
).data.product
seeder = await createOrderSeeder({
api,
container: getContainer(),
productOverride,
additionalProducts: [productOverride2],
stockChannelOverride,
inventoryItemOverride,
})
order = seeder.order order = seeder.order
order = (await api.get(`/admin/orders/${order.id}`, adminHeaders)).data order = (await api.get(`/admin/orders/${order.id}`, adminHeaders)).data
.order .order
}) })
it("should only create fulfillments grouped by shipping requirement", async () => {
const {
response: { data },
} = await api
.post(
`/admin/orders/${order.id}/fulfillments`,
{
location_id: seeder.stockLocation.id,
items: [
{
id: order.items[0].id,
quantity: 1,
},
{
id: order.items[1].id,
quantity: 1,
},
],
},
adminHeaders
)
.catch((e) => e)
expect(data).toEqual({
type: "invalid_data",
message: `Fulfillment can only be created entirely with items with shipping or items without shipping. Split this request into 2 fulfillments.`,
})
const {
data: { order: fulfillableOrder },
} = await api.post(
`/admin/orders/${order.id}/fulfillments?fields=+fulfillments.id,fulfillments.requires_shipping`,
{
location_id: seeder.stockLocation.id,
items: [{ id: order.items[0].id, quantity: 1 }],
},
adminHeaders
)
expect(fulfillableOrder.fulfillments).toHaveLength(1)
const {
data: { order: fulfillableOrder2 },
} = await api.post(
`/admin/orders/${order.id}/fulfillments?fields=+fulfillments.id,fulfillments.requires_shipping`,
{
location_id: seeder.stockLocation.id,
items: [{ id: order.items[1].id, quantity: 1 }],
},
adminHeaders
)
expect(fulfillableOrder2.fulfillments).toHaveLength(2)
})
})
describe("POST /orders/:id/fulfillments/:id/mark-as-delivered", () => { describe("POST /orders/:id/fulfillments/:id/mark-as-delivered", () => {
beforeEach(async () => {
seeder = await createOrderSeeder({ api, container: getContainer() })
order = seeder.order
order = (await api.get(`/admin/orders/${order.id}`, adminHeaders)).data
.order
})
it("should mark fulfillable item as delivered", async () => { it("should mark fulfillable item as delivered", async () => {
let fulfillableItem = order.items.find( let fulfillableItem = order.items.find(
(item) => item.detail.fulfilled_quantity < item.detail.quantity (item) => item.detail.fulfilled_quantity < item.detail.quantity
@@ -1091,6 +1091,7 @@
"statusTitle": "Fulfillment Status", "statusTitle": "Fulfillment Status",
"fulfillItems": "Fulfill items", "fulfillItems": "Fulfill items",
"awaitingFulfillmentBadge": "Awaiting fulfillment", "awaitingFulfillmentBadge": "Awaiting fulfillment",
"requiresShipping": "Requires shipping",
"number": "Fulfillment #{{number}}", "number": "Fulfillment #{{number}}",
"itemsToFulfill": "Items to fulfill", "itemsToFulfill": "Items to fulfill",
"create": "Create Fulfillment", "create": "Create Fulfillment",
+2 -2
View File
@@ -2,14 +2,14 @@ import { AdminOrderLineItem } from "@medusajs/types"
export function getReturnableQuantity(item: AdminOrderLineItem): number { export function getReturnableQuantity(item: AdminOrderLineItem): number {
const { const {
shipped_quantity, delivered_quantity,
return_received_quantity, return_received_quantity,
return_dismissed_quantity, return_dismissed_quantity,
return_requested_quantity, return_requested_quantity,
} = item.detail } = item.detail
return ( return (
shipped_quantity - delivered_quantity -
(return_received_quantity + (return_received_quantity +
return_requested_quantity + return_requested_quantity +
return_dismissed_quantity) return_dismissed_quantity)
@@ -8,6 +8,7 @@ import { Alert, Button, Select, Switch, toast } from "@medusajs/ui"
import { useForm, useWatch } from "react-hook-form" import { useForm, useWatch } from "react-hook-form"
import { OrderLineItemDTO } from "@medusajs/types" import { OrderLineItemDTO } from "@medusajs/types"
import { useSearchParams } from "react-router-dom"
import { Form } from "../../../../../components/common/form" import { Form } from "../../../../../components/common/form"
import { import {
RouteFocusModal, RouteFocusModal,
@@ -16,40 +17,48 @@ import {
import { useCreateOrderFulfillment } from "../../../../../hooks/api/orders" import { useCreateOrderFulfillment } from "../../../../../hooks/api/orders"
import { useStockLocations } from "../../../../../hooks/api/stock-locations" import { useStockLocations } from "../../../../../hooks/api/stock-locations"
import { getFulfillableQuantity } from "../../../../../lib/order-item" import { getFulfillableQuantity } from "../../../../../lib/order-item"
import { OrderCreateFulfillmentItem } from "./order-create-fulfillment-item"
import { CreateFulfillmentSchema } from "./constants" import { CreateFulfillmentSchema } from "./constants"
import { useShippingOptions } from "../../../../../hooks/api" import { OrderCreateFulfillmentItem } from "./order-create-fulfillment-item"
type OrderCreateFulfillmentFormProps = { type OrderCreateFulfillmentFormProps = {
order: AdminOrder order: AdminOrder
requiresShipping: boolean
} }
export function OrderCreateFulfillmentForm({ export function OrderCreateFulfillmentForm({
order, order,
requiresShipping,
}: OrderCreateFulfillmentFormProps) { }: OrderCreateFulfillmentFormProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { handleSuccess } = useRouteModal() const { handleSuccess } = useRouteModal()
const [searchParams] = useSearchParams()
const { mutateAsync: createOrderFulfillment, isPending: isMutating } = const { mutateAsync: createOrderFulfillment, isPending: isMutating } =
useCreateOrderFulfillment(order.id) useCreateOrderFulfillment(order.id)
const [fulfillableItems, setFulfillableItems] = useState(() => const [fulfillableItems, setFulfillableItems] = useState(() =>
(order.items || []).filter((item) => getFulfillableQuantity(item) > 0) (order.items || []).filter(
(item) =>
item.requires_shipping === requiresShipping &&
getFulfillableQuantity(item) > 0
)
) )
const form = useForm<zod.infer<typeof CreateFulfillmentSchema>>({ const form = useForm<zod.infer<typeof CreateFulfillmentSchema>>({
defaultValues: { defaultValues: {
quantity: fulfillableItems.reduce((acc, item) => { quantity: fulfillableItems.reduce(
(acc, item) => {
acc[item.id] = getFulfillableQuantity(item) acc[item.id] = getFulfillableQuantity(item)
return acc return acc
}, {} as Record<string, number>), },
{} as Record<string, number>
),
send_notification: !order.no_notification, send_notification: !order.no_notification,
}, },
resolver: zodResolver(CreateFulfillmentSchema), resolver: zodResolver(CreateFulfillmentSchema),
}) })
const { stock_locations = [] } = useStockLocations() const { stock_locations = [] } = useStockLocations()
const { shipping_options = [] } = useShippingOptions()
const handleSubmit = form.handleSubmit(async (data) => { const handleSubmit = form.handleSubmit(async (data) => {
try { try {
@@ -84,12 +93,18 @@ export function OrderCreateFulfillmentForm({
}) })
const fulfilledQuantityArray = (order.items || []).map( const fulfilledQuantityArray = (order.items || []).map(
(item) => item.detail.fulfilled_quantity (item) =>
item.requires_shipping === requiresShipping &&
item.detail.fulfilled_quantity
) )
useEffect(() => { useEffect(() => {
const itemsToFulfill = const itemsToFulfill =
order?.items?.filter((item) => getFulfillableQuantity(item) > 0) || [] order?.items?.filter(
(item) =>
item.requires_shipping === requiresShipping &&
getFulfillableQuantity(item) > 0
) || []
setFulfillableItems(itemsToFulfill) setFulfillableItems(itemsToFulfill)
@@ -102,13 +117,16 @@ export function OrderCreateFulfillmentForm({
}) })
} }
const quantityMap = itemsToFulfill.reduce((acc, item) => { const quantityMap = itemsToFulfill.reduce(
(acc, item) => {
acc[item.id] = getFulfillableQuantity(item as OrderLineItemDTO) acc[item.id] = getFulfillableQuantity(item as OrderLineItemDTO)
return acc return acc
}, {} as Record<string, number>) },
{} as Record<string, number>
)
form.setValue("quantity", quantityMap) form.setValue("quantity", quantityMap)
}, [...fulfilledQuantityArray]) }, [...fulfilledQuantityArray, requiresShipping])
return ( return (
<RouteFocusModal.Form form={form}> <RouteFocusModal.Form form={form}>
@@ -1,4 +1,4 @@
import { useParams } from "react-router-dom" import { useParams, useSearchParams } from "react-router-dom"
import { RouteFocusModal } from "../../../components/modals" import { RouteFocusModal } from "../../../components/modals"
import { useOrder } from "../../../hooks/api/orders" import { useOrder } from "../../../hooks/api/orders"
@@ -6,6 +6,8 @@ import { OrderCreateFulfillmentForm } from "./components/order-create-fulfillmen
export function OrderCreateFulfillment() { export function OrderCreateFulfillment() {
const { id } = useParams() const { id } = useParams()
const [searchParams] = useSearchParams()
const requiresShipping = searchParams.get("requires_shipping") === "true"
const { order, isLoading, isError, error } = useOrder(id!, { const { order, isLoading, isError, error } = useOrder(id!, {
fields: "currency_code,*items,*items.variant,*shipping_address", fields: "currency_code,*items,*items.variant,*shipping_address",
@@ -19,7 +21,12 @@ export function OrderCreateFulfillment() {
return ( return (
<RouteFocusModal> <RouteFocusModal>
{ready && <OrderCreateFulfillmentForm order={order} />} {ready && (
<OrderCreateFulfillmentForm
order={order}
requiresShipping={requiresShipping}
/>
)}
</RouteFocusModal> </RouteFocusModal>
) )
} }
@@ -2,6 +2,7 @@ import { Buildings, XCircle } from "@medusajs/icons"
import { import {
AdminOrder, AdminOrder,
AdminOrderFulfillment, AdminOrderFulfillment,
AdminOrderLineItem,
HttpTypes, HttpTypes,
OrderLineItemDTO, OrderLineItemDTO,
} from "@medusajs/types" } from "@medusajs/types"
@@ -108,25 +109,64 @@ const UnfulfilledItem = ({
} }
const UnfulfilledItemBreakdown = ({ order }: { order: AdminOrder }) => { 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 // Create an array of order items that haven't been fulfilled or at least not fully fulfilled
const unfulfilledItems = order.items!.filter( const unfulfilledItemsWithShipping = order.items!.filter(
(i) => i.detail.fulfilled_quantity < i.quantity (i) => i.requires_shipping && i.detail.fulfilled_quantity < i.quantity
) )
if (!unfulfilledItems.length) { const unfulfilledItemsWithoutShipping = order.items!.filter(
return null (i) => !i.requires_shipping && i.detail.fulfilled_quantity < i.quantity
)
return (
<>
{!!unfulfilledItemsWithShipping.length && (
<UnfulfilledItemDisplay
order={order}
unfulfilledItems={unfulfilledItemsWithShipping}
requiresShipping={true}
/>
)}
{!!unfulfilledItemsWithoutShipping.length && (
<UnfulfilledItemDisplay
order={order}
unfulfilledItems={unfulfilledItemsWithoutShipping}
requiresShipping={false}
/>
)}
</>
)
} }
const UnfulfilledItemDisplay = ({
order,
unfulfilledItems,
requiresShipping = false,
}: {
order: AdminOrder
unfulfilledItems: AdminOrderLineItem[]
requiresShipping: boolean
}) => {
const { t } = useTranslation()
return ( return (
<Container className="divide-y p-0"> <Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4"> <div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">{t("orders.fulfillment.unfulfilledItems")}</Heading> <Heading level="h2">{t("orders.fulfillment.unfulfilledItems")}</Heading>
<div className="flex items-center gap-x-4"> <div className="flex items-center gap-x-4">
{requiresShipping && (
<StatusBadge color="red" className="text-nowrap">
{t("orders.fulfillment.requiresShipping")}
</StatusBadge>
)}
<StatusBadge color="red" className="text-nowrap"> <StatusBadge color="red" className="text-nowrap">
{t("orders.fulfillment.awaitingFulfillmentBadge")} {t("orders.fulfillment.awaitingFulfillmentBadge")}
</StatusBadge> </StatusBadge>
<ActionMenu <ActionMenu
groups={[ groups={[
{ {
@@ -134,7 +174,7 @@ const UnfulfilledItemBreakdown = ({ order }: { order: AdminOrder }) => {
{ {
label: t("orders.fulfillment.fulfillItems"), label: t("orders.fulfillment.fulfillItems"),
icon: <Buildings />, icon: <Buildings />,
to: `/orders/${order.id}/fulfillment`, to: `/orders/${order.id}/fulfillment?requires_shipping=${requiresShipping}`,
}, },
], ],
}, },
@@ -143,7 +183,7 @@ const UnfulfilledItemBreakdown = ({ order }: { order: AdminOrder }) => {
</div> </div>
</div> </div>
<div> <div>
{unfulfilledItems.map((item) => ( {unfulfilledItems.map((item: AdminOrderLineItem) => (
<UnfulfilledItem <UnfulfilledItem
key={item.id} key={item.id}
item={item} item={item}
@@ -178,7 +218,9 @@ const Fulfillment = ({
} }
) )
let statusText = "Awaiting shipping" let statusText = fulfillment.requires_shipping
? "Awaiting shipping"
: "Awaiting delivery"
let statusColor: "blue" | "green" | "red" = "blue" let statusColor: "blue" | "green" | "red" = "blue"
let statusTimestamp = fulfillment.created_at let statusTimestamp = fulfillment.created_at
@@ -205,7 +247,8 @@ const Fulfillment = ({
const showShippingButton = const showShippingButton =
!fulfillment.canceled_at && !fulfillment.canceled_at &&
!fulfillment.shipped_at && !fulfillment.shipped_at &&
!fulfillment.delivered_at !fulfillment.delivered_at &&
fulfillment.requires_shipping
const showDeliveryButton = const showDeliveryButton =
!fulfillment.canceled_at && !fulfillment.delivered_at !fulfillment.canceled_at && !fulfillment.delivered_at
@@ -87,6 +87,7 @@ export const completeCartFields = [
"items.variant.allow_backorder", "items.variant.allow_backorder",
"items.variant.inventory_items.inventory_item_id", "items.variant.inventory_items.inventory_item_id",
"items.variant.inventory_items.required_quantity", "items.variant.inventory_items.required_quantity",
"items.variant.inventory_items.inventory.requires_shipping",
"items.variant.inventory_items.inventory.location_levels.stock_locations.id", "items.variant.inventory_items.inventory.location_levels.stock_locations.id",
"items.variant.inventory_items.inventory.location_levels.stock_locations.name", "items.variant.inventory_items.inventory.location_levels.stock_locations.name",
"items.variant.inventory_items.inventory.location_levels.stock_locations.sales_channels.id", "items.variant.inventory_items.inventory.location_levels.stock_locations.sales_channels.id",
@@ -116,6 +117,7 @@ export const productVariantsFields = [
"calculated_price.is_calculated_price_tax_inclusive", "calculated_price.is_calculated_price_tax_inclusive",
"inventory_items.inventory_item_id", "inventory_items.inventory_item_id",
"inventory_items.required_quantity", "inventory_items.required_quantity",
"inventory_items.inventory.requires_shipping",
"inventory_items.inventory.location_levels.stock_locations.id", "inventory_items.inventory.location_levels.stock_locations.id",
"inventory_items.inventory.location_levels.stock_locations.name", "inventory_items.inventory.location_levels.stock_locations.name",
"inventory_items.inventory.location_levels.stock_locations.sales_channels.id", "inventory_items.inventory.location_levels.stock_locations.sales_channels.id",
@@ -3,8 +3,10 @@ import {
CartLineItemDTO, CartLineItemDTO,
CreateOrderAdjustmentDTO, CreateOrderAdjustmentDTO,
CreateOrderLineItemTaxLineDTO, CreateOrderLineItemTaxLineDTO,
InventoryItemDTO,
ProductVariantDTO, ProductVariantDTO,
} from "@medusajs/types" } from "@medusajs/types"
import { isDefined } from "@medusajs/utils"
interface Input { interface Input {
item?: CartLineItemDTO item?: CartLineItemDTO
@@ -12,7 +14,9 @@ interface Input {
metadata?: Record<string, any> metadata?: Record<string, any>
unitPrice: BigNumberInput unitPrice: BigNumberInput
isTaxInclusive?: boolean isTaxInclusive?: boolean
variant: ProductVariantDTO variant: ProductVariantDTO & {
inventory_items: { inventory: InventoryItemDTO }[]
}
taxLines?: CreateOrderLineItemTaxLineDTO[] taxLines?: CreateOrderLineItemTaxLineDTO[]
adjustments?: CreateOrderAdjustmentDTO[] adjustments?: CreateOrderAdjustmentDTO[]
cartId?: string cartId?: string
@@ -35,6 +39,19 @@ export function prepareLineItemData(data: Input) {
throw new Error("Variant does not have a product") throw new Error("Variant does not have a product")
} }
// Note: If any of the items require shipping, we enable fulfillment
// unless explicitly set to not require shipping by the item in the request
const { inventory_items: inventoryItems } = variant
const someInventoryRequiresShipping = inventoryItems.length
? inventoryItems.some(
(inventoryItem) => !!inventoryItem.inventory.requires_shipping
)
: true
const requiresShipping = isDefined(item?.requires_shipping)
? item.requires_shipping
: someInventoryRequiresShipping
const lineItem: any = { const lineItem: any = {
quantity, quantity,
title: variant.title ?? item?.title, title: variant.title ?? item?.title,
@@ -58,7 +75,7 @@ export function prepareLineItemData(data: Input) {
variant_option_values: item?.variant_option_values, variant_option_values: item?.variant_option_values,
is_discountable: variant.product.discountable ?? item?.is_discountable, is_discountable: variant.product.discountable ?? item?.is_discountable,
requires_shipping: variant.requires_shipping ?? item?.requires_shipping, requires_shipping: requiresShipping,
unit_price: unitPrice, unit_price: unitPrice,
is_tax_inclusive: !!isTaxInclusive, is_tax_inclusive: !!isTaxInclusive,
@@ -16,6 +16,7 @@ export const productVariantsFields = [
"calculated_price.calculated_amount", "calculated_price.calculated_amount",
"inventory_items.inventory_item_id", "inventory_items.inventory_item_id",
"inventory_items.required_quantity", "inventory_items.required_quantity",
"inventory_items.inventory.requires_shipping",
"inventory_items.inventory.location_levels.stock_locations.id", "inventory_items.inventory.location_levels.stock_locations.id",
"inventory_items.inventory.location_levels.stock_locations.name", "inventory_items.inventory.location_levels.stock_locations.name",
"inventory_items.inventory.location_levels.stock_locations.sales_channels.id", "inventory_items.inventory.location_levels.stock_locations.sales_channels.id",
@@ -1,6 +1,7 @@
import { import {
OrderChangeDTO, OrderChangeDTO,
OrderDTO, OrderDTO,
OrderLineItemDTO,
OrderWorkflow, OrderWorkflow,
ReturnDTO, ReturnDTO,
} from "@medusajs/types" } from "@medusajs/types"
@@ -41,6 +42,37 @@ export function throwIfItemsDoesNotExistsInOrder({
} }
} }
export function throwIfItemsAreNotGroupedByShippingRequirement({
order,
inputItems,
}: {
order: Pick<OrderDTO, "id" | "items">
inputItems: OrderWorkflow.CreateOrderFulfillmentWorkflowInput["items"]
}) {
const itemsWithShipping: string[] = []
const itemsWithoutShipping: string[] = []
const orderItemsMap = new Map<string, OrderLineItemDTO>(
(order.items || []).map((item) => [item.id, item])
)
for (const inputItem of inputItems) {
const orderItem = orderItemsMap.get(inputItem.id)!
if (orderItem.requires_shipping) {
itemsWithShipping.push(orderItem.id)
} else {
itemsWithoutShipping.push(orderItem.id)
}
}
if (itemsWithShipping.length && itemsWithoutShipping.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Fulfillment can only be created entirely with items with shipping or items without shipping. Split this request into 2 fulfillments.`
)
}
}
export function throwIfIsCancelled( export function throwIfIsCancelled(
obj: unknown & { id: string; canceled_at?: any }, obj: unknown & { id: string; canceled_at?: any },
type: string type: string
@@ -26,6 +26,7 @@ import {
} from "../../reservation" } from "../../reservation"
import { registerOrderFulfillmentStep } from "../steps" import { registerOrderFulfillmentStep } from "../steps"
import { import {
throwIfItemsAreNotGroupedByShippingRequirement,
throwIfItemsDoesNotExistsInOrder, throwIfItemsDoesNotExistsInOrder,
throwIfOrderIsCancelled, throwIfOrderIsCancelled,
} from "../utils/order-validation" } from "../utils/order-validation"
@@ -35,18 +36,16 @@ import {
*/ */
export const createFulfillmentValidateOrder = createStep( export const createFulfillmentValidateOrder = createStep(
"create-fulfillment-validate-order", "create-fulfillment-validate-order",
( ({
{
order, order,
inputItems, inputItems,
}: { }: {
order: OrderDTO order: OrderDTO
inputItems: OrderWorkflow.CreateOrderFulfillmentWorkflowInput["items"] inputItems: OrderWorkflow.CreateOrderFulfillmentWorkflowInput["items"]
}, }) => {
context
) => {
throwIfOrderIsCancelled({ order }) throwIfOrderIsCancelled({ order })
throwIfItemsDoesNotExistsInOrder({ order, inputItems }) throwIfItemsDoesNotExistsInOrder({ order, inputItems })
throwIfItemsAreNotGroupedByShippingRequirement({ order, inputItems })
} }
) )
@@ -91,16 +90,29 @@ function prepareFulfillmentData({
reservations: ReservationItemDTO[] reservations: ReservationItemDTO[]
itemsList?: OrderLineItemDTO[] itemsList?: OrderLineItemDTO[]
}) { }) {
const inputItems = input.items const fulfillableItems = input.items
const orderItemsMap = new Map<string, Required<OrderDTO>["items"][0]>( const orderItemsMap = new Map<string, Required<OrderDTO>["items"][0]>(
(itemsList ?? order.items)!.map((i) => [i.id, i]) (itemsList ?? order.items)!.map((i) => [i.id, i])
) )
const reservationItemMap = new Map<string, ReservationItemDTO>( const reservationItemMap = new Map<string, ReservationItemDTO>(
reservations.map((r) => [r.line_item_id as string, r]) reservations.map((r) => [r.line_item_id as string, r])
) )
const fulfillmentItems = inputItems.map((i) => {
// Note: If any of the items require shipping, we enable fulfillment
// unless explicitly set to not require shipping by the item in the request
const someItemsRequireShipping = fulfillableItems.length
? fulfillableItems.some((item) => {
const orderItem = orderItemsMap.get(item.id)!
return orderItem.requires_shipping
})
: true
const fulfillmentItems = fulfillableItems.map((i) => {
const orderItem = orderItemsMap.get(i.id)! const orderItem = orderItemsMap.get(i.id)!
const reservation = reservationItemMap.get(i.id)! const reservation = reservationItemMap.get(i.id)!
return { return {
line_item_id: i.id, line_item_id: i.id,
inventory_item_id: reservation?.inventory_item_id, inventory_item_id: reservation?.inventory_item_id,
@@ -134,6 +146,7 @@ function prepareFulfillmentData({
shipping_option_id: shippingOption.id, shipping_option_id: shippingOption.id,
data: shippingMethod.data, data: shippingMethod.data,
items: fulfillmentItems, items: fulfillmentItems,
requires_shipping: someItemsRequireShipping,
labels: input.labels ?? [], labels: input.labels ?? [],
delivery_address: shippingAddress as any, delivery_address: shippingAddress as any,
packed_at: new Date(), packed_at: new Date(),
@@ -8,7 +8,6 @@ import {
parallelize, parallelize,
transform, transform,
} from "@medusajs/workflows-sdk" } from "@medusajs/workflows-sdk"
import { useRemoteQueryStep } from "../../common"
import { findOneOrAnyRegionStep } from "../../cart/steps/find-one-or-any-region" import { findOneOrAnyRegionStep } from "../../cart/steps/find-one-or-any-region"
import { findOrCreateCustomerStep } from "../../cart/steps/find-or-create-customer" import { findOrCreateCustomerStep } from "../../cart/steps/find-or-create-customer"
import { findSalesChannelStep } from "../../cart/steps/find-sales-channel" import { findSalesChannelStep } from "../../cart/steps/find-sales-channel"
@@ -16,6 +15,7 @@ import { getVariantPriceSetsStep } from "../../cart/steps/get-variant-price-sets
import { validateVariantPricesStep } from "../../cart/steps/validate-variant-prices" import { validateVariantPricesStep } from "../../cart/steps/validate-variant-prices"
import { prepareLineItemData } from "../../cart/utils/prepare-line-item-data" import { prepareLineItemData } from "../../cart/utils/prepare-line-item-data"
import { confirmVariantInventoryWorkflow } from "../../cart/workflows/confirm-variant-inventory" import { confirmVariantInventoryWorkflow } from "../../cart/workflows/confirm-variant-inventory"
import { useRemoteQueryStep } from "../../common"
import { createOrdersStep } from "../steps" import { createOrdersStep } from "../steps"
import { productVariantsFields } from "../utils/fields" import { productVariantsFields } from "../utils/fields"
import { prepareCustomLineItemData } from "../utils/prepare-custom-line-item-data" import { prepareCustomLineItemData } from "../utils/prepare-custom-line-item-data"
@@ -75,6 +75,11 @@ export interface FulfillmentDTO {
*/ */
shipping_option: ShippingOptionDTO | null shipping_option: ShippingOptionDTO | null
/**
* Flag to indidcate whether shipping is required
*/
requires_shipping: boolean
/** /**
* The associated fulfillment provider. * The associated fulfillment provider.
*/ */
@@ -52,6 +52,11 @@ export interface CreateFulfillmentDTO {
*/ */
shipping_option_id?: string | null shipping_option_id?: string | null
/**
* Flag to indicate whether shipping is required
*/
requires_shipping?: boolean
/** /**
* Holds custom data in key-value pairs. * Holds custom data in key-value pairs.
*/ */
@@ -20,6 +20,7 @@ export interface AdminOrder extends BaseOrder {
customer?: AdminCustomer customer?: AdminCustomer
shipping_address?: AdminOrderAddress | null shipping_address?: AdminOrderAddress | null
billing_address?: AdminOrderAddress | null billing_address?: AdminOrderAddress | null
items: AdminOrderLineItem[]
} }
export interface AdminOrderLineItem extends BaseOrderLineItem { export interface AdminOrderLineItem extends BaseOrderLineItem {
@@ -240,6 +240,7 @@ export interface BaseOrderFulfillment {
shipped_at: Date | null shipped_at: Date | null
delivered_at: Date | null delivered_at: Date | null
canceled_at: Date | null canceled_at: Date | null
requires_shipping: boolean
data: Record<string, unknown> | null data: Record<string, unknown> | null
provider_id: string provider_id: string
shipping_option_id: string | null shipping_option_id: string | null
@@ -1,4 +1,5 @@
import { createOrderFulfillmentWorkflow } from "@medusajs/core-flows" import { createOrderFulfillmentWorkflow } from "@medusajs/core-flows"
import { AdditionalData, HttpTypes } from "@medusajs/types"
import { import {
ContainerRegistrationKeys, ContainerRegistrationKeys,
remoteQueryObjectFromString, remoteQueryObjectFromString,
@@ -8,7 +9,6 @@ import {
MedusaResponse, MedusaResponse,
} from "../../../../../types/routing" } from "../../../../../types/routing"
import { AdminOrderCreateFulfillmentType } from "../../validators" import { AdminOrderCreateFulfillmentType } from "../../validators"
import { AdditionalData, HttpTypes } from "@medusajs/types"
export const POST = async ( export const POST = async (
req: AuthenticatedMedusaRequest< req: AuthenticatedMedusaRequest<
@@ -18,20 +18,16 @@ export const POST = async (
) => { ) => {
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
const variables = { id: req.params.id } await createOrderFulfillmentWorkflow(req.scope).run({
input: {
const input = {
...req.validatedBody, ...req.validatedBody,
order_id: req.params.id, order_id: req.params.id,
} },
await createOrderFulfillmentWorkflow(req.scope).run({
input,
}) })
const queryObject = remoteQueryObjectFromString({ const queryObject = remoteQueryObjectFromString({
entryPoint: "order", entryPoint: "order",
variables, variables: { id: req.params.id },
fields: req.remoteQueryConfig.fields, fields: req.remoteQueryConfig.fields,
}) })
@@ -47,6 +47,7 @@ export const defaultStoreCartFields = [
"items.variant_sku", "items.variant_sku",
"items.variant_barcode", "items.variant_barcode",
"items.variant_title", "items.variant_title",
"items.requires_shipping",
"items.metadata", "items.metadata",
"items.created_at", "items.created_at",
"items.updated_at", "items.updated_at",
@@ -1359,6 +1359,16 @@
"nullable": true, "nullable": true,
"mappedType": "text" "mappedType": "text"
}, },
"requires_shipping": {
"name": "requires_shipping",
"type": "boolean",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"default": "true",
"mappedType": "boolean"
},
"created_at": { "created_at": {
"name": "created_at", "name": "created_at",
"type": "timestamptz", "type": "timestamptz",
@@ -0,0 +1,15 @@
import { Migration } from "@mikro-orm/migrations"
export class Migration20240917161003 extends Migration {
async up(): Promise<void> {
this.addSql(
'alter table if exists "fulfillment" add column if not exists "requires_shipping" boolean not null default true;'
)
}
async down(): Promise<void> {
this.addSql(
'alter table if exists "fulfillment" drop column if exists "requires_shipping";'
)
}
}
@@ -135,6 +135,9 @@ export default class Fulfillment {
}) })
delivery_address!: Rel<FulfillmentAddress> delivery_address!: Rel<FulfillmentAddress>
@Property({ columnType: "boolean", default: true })
requires_shipping: boolean = true
@OneToMany(() => FulfillmentItem, (item) => item.fulfillment, { @OneToMany(() => FulfillmentItem, (item) => item.fulfillment, {
cascade: [Cascade.PERSIST, "soft-remove"] as any, cascade: [Cascade.PERSIST, "soft-remove"] as any,
orphanRemoval: true, orphanRemoval: true,