diff --git a/integration-tests/http/__tests__/fixtures/order.ts b/integration-tests/http/__tests__/fixtures/order.ts index 78ea700233ed8..c1511b531a03c 100644 --- a/integration-tests/http/__tests__/fixtures/order.ts +++ b/integration-tests/http/__tests__/fixtures/order.ts @@ -1,10 +1,30 @@ +import { + AdminInventoryItem, + AdminProduct, + AdminStockLocation, + MedusaContainer, +} from "@medusajs/types" import { adminHeaders, generatePublishableKey, generateStoreHeaders, } 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 storeHeaders = generateStoreHeaders({ publishableKey }) @@ -24,21 +44,25 @@ export async function createOrderSeeder({ api, container }) { ) ).data.sales_channel - const stockLocation = ( - await api.post( - `/admin/stock-locations`, - { name: "test location" }, - adminHeaders - ) - ).data.stock_location - - const inventoryItem = ( - await api.post( - `/admin/inventory-items`, - { sku: "test-variant" }, - adminHeaders - ) - ).data.inventory_item + const stockLocation = + stockChannelOverride ?? + ( + await api.post( + `/admin/stock-locations`, + { name: "test location" }, + adminHeaders + ) + ).data.stock_location + + const inventoryItem = + inventoryItemOverride ?? + ( + await api.post( + `/admin/inventory-items`, + { sku: "test-variant" }, + adminHeaders + ) + ).data.inventory_item await api.post( `/admin/inventory-items/${inventoryItem.id}/location-levels`, @@ -63,41 +87,43 @@ export async function createOrderSeeder({ api, container }) { ) ).data.shipping_profile - const product = ( - await api.post( - "/admin/products", - { - title: `Test fixture ${shippingProfile.id}`, - options: [ - { title: "size", values: ["large", "small"] }, - { title: "color", values: ["green"] }, - ], - variants: [ - { - title: "Test variant", - sku: "test-variant", - inventory_items: [ - { - inventory_item_id: inventoryItem.id, - required_quantity: 1, - }, - ], - prices: [ - { - currency_code: "usd", - amount: 100, + const product = + productOverride ?? + ( + await api.post( + "/admin/products", + { + title: `Test fixture ${shippingProfile.id}`, + options: [ + { title: "size", values: ["large", "small"] }, + { title: "color", values: ["green"] }, + ], + variants: [ + { + title: "Test variant", + sku: "test-variant", + inventory_items: [ + { + inventory_item_id: inventoryItem.id, + required_quantity: 1, + }, + ], + prices: [ + { + currency_code: "usd", + amount: 100, + }, + ], + options: { + size: "large", + color: "green", }, - ], - options: { - size: "large", - color: "green", }, - }, - ], - }, - adminHeaders - ) - ).data.product + ], + }, + adminHeaders + ) + ).data.product const fulfillmentSets = ( await api.post( @@ -167,7 +193,13 @@ export async function createOrderSeeder({ api, container }) { postal_code: "94016", }, 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 ) diff --git a/integration-tests/http/__tests__/order/admin/order.spec.ts b/integration-tests/http/__tests__/order/admin/order.spec.ts index 3390f980aa7e8..509ab8efed943 100644 --- a/integration-tests/http/__tests__/order/admin/order.spec.ts +++ b/integration-tests/http/__tests__/order/admin/order.spec.ts @@ -18,13 +18,192 @@ medusaIntegrationTestRunner({ 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", () => { + 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 = (await api.get(`/admin/orders/${order.id}`, adminHeaders)).data + .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", () => { + 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 () => { let fulfillableItem = order.items.find( (item) => item.detail.fulfilled_quantity < item.detail.quantity diff --git a/packages/admin/dashboard/src/i18n/translations/en.json b/packages/admin/dashboard/src/i18n/translations/en.json index f3763ea143bda..28092fc0e7414 100644 --- a/packages/admin/dashboard/src/i18n/translations/en.json +++ b/packages/admin/dashboard/src/i18n/translations/en.json @@ -1091,6 +1091,7 @@ "statusTitle": "Fulfillment Status", "fulfillItems": "Fulfill items", "awaitingFulfillmentBadge": "Awaiting fulfillment", + "requiresShipping": "Requires shipping", "number": "Fulfillment #{{number}}", "itemsToFulfill": "Items to fulfill", "create": "Create Fulfillment", diff --git a/packages/admin/dashboard/src/lib/rma.ts b/packages/admin/dashboard/src/lib/rma.ts index 890a491c1fed2..44565998c0d06 100644 --- a/packages/admin/dashboard/src/lib/rma.ts +++ b/packages/admin/dashboard/src/lib/rma.ts @@ -2,14 +2,14 @@ import { AdminOrderLineItem } from "@medusajs/types" export function getReturnableQuantity(item: AdminOrderLineItem): number { const { - shipped_quantity, + delivered_quantity, return_received_quantity, return_dismissed_quantity, return_requested_quantity, } = item.detail return ( - shipped_quantity - + delivered_quantity - (return_received_quantity + return_requested_quantity + return_dismissed_quantity) diff --git a/packages/admin/dashboard/src/routes/orders/order-create-fulfillment/components/order-create-fulfillment-form/order-create-fulfillment-form.tsx b/packages/admin/dashboard/src/routes/orders/order-create-fulfillment/components/order-create-fulfillment-form/order-create-fulfillment-form.tsx index 213a5308f2b43..9b8480dedcbc8 100644 --- a/packages/admin/dashboard/src/routes/orders/order-create-fulfillment/components/order-create-fulfillment-form/order-create-fulfillment-form.tsx +++ b/packages/admin/dashboard/src/routes/orders/order-create-fulfillment/components/order-create-fulfillment-form/order-create-fulfillment-form.tsx @@ -8,6 +8,7 @@ import { Alert, Button, Select, Switch, toast } from "@medusajs/ui" import { useForm, useWatch } from "react-hook-form" import { OrderLineItemDTO } from "@medusajs/types" +import { useSearchParams } from "react-router-dom" import { Form } from "../../../../../components/common/form" import { RouteFocusModal, @@ -16,40 +17,48 @@ import { import { useCreateOrderFulfillment } from "../../../../../hooks/api/orders" import { useStockLocations } from "../../../../../hooks/api/stock-locations" import { getFulfillableQuantity } from "../../../../../lib/order-item" -import { OrderCreateFulfillmentItem } from "./order-create-fulfillment-item" import { CreateFulfillmentSchema } from "./constants" -import { useShippingOptions } from "../../../../../hooks/api" +import { OrderCreateFulfillmentItem } from "./order-create-fulfillment-item" type OrderCreateFulfillmentFormProps = { order: AdminOrder + requiresShipping: boolean } export function OrderCreateFulfillmentForm({ order, + requiresShipping, }: OrderCreateFulfillmentFormProps) { const { t } = useTranslation() const { handleSuccess } = useRouteModal() + const [searchParams] = useSearchParams() const { mutateAsync: createOrderFulfillment, isPending: isMutating } = useCreateOrderFulfillment(order.id) 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>({ defaultValues: { - quantity: fulfillableItems.reduce((acc, item) => { - acc[item.id] = getFulfillableQuantity(item) - return acc - }, {} as Record), + quantity: fulfillableItems.reduce( + (acc, item) => { + acc[item.id] = getFulfillableQuantity(item) + return acc + }, + {} as Record + ), send_notification: !order.no_notification, }, resolver: zodResolver(CreateFulfillmentSchema), }) const { stock_locations = [] } = useStockLocations() - const { shipping_options = [] } = useShippingOptions() const handleSubmit = form.handleSubmit(async (data) => { try { @@ -84,12 +93,18 @@ export function OrderCreateFulfillmentForm({ }) const fulfilledQuantityArray = (order.items || []).map( - (item) => item.detail.fulfilled_quantity + (item) => + item.requires_shipping === requiresShipping && + item.detail.fulfilled_quantity ) useEffect(() => { const itemsToFulfill = - order?.items?.filter((item) => getFulfillableQuantity(item) > 0) || [] + order?.items?.filter( + (item) => + item.requires_shipping === requiresShipping && + getFulfillableQuantity(item) > 0 + ) || [] setFulfillableItems(itemsToFulfill) @@ -102,13 +117,16 @@ export function OrderCreateFulfillmentForm({ }) } - const quantityMap = itemsToFulfill.reduce((acc, item) => { - acc[item.id] = getFulfillableQuantity(item as OrderLineItemDTO) - return acc - }, {} as Record) + const quantityMap = itemsToFulfill.reduce( + (acc, item) => { + acc[item.id] = getFulfillableQuantity(item as OrderLineItemDTO) + return acc + }, + {} as Record + ) form.setValue("quantity", quantityMap) - }, [...fulfilledQuantityArray]) + }, [...fulfilledQuantityArray, requiresShipping]) return ( diff --git a/packages/admin/dashboard/src/routes/orders/order-create-fulfillment/order-create-fulfillments.tsx b/packages/admin/dashboard/src/routes/orders/order-create-fulfillment/order-create-fulfillments.tsx index 10f725df9ef36..a79277383b2f8 100644 --- a/packages/admin/dashboard/src/routes/orders/order-create-fulfillment/order-create-fulfillments.tsx +++ b/packages/admin/dashboard/src/routes/orders/order-create-fulfillment/order-create-fulfillments.tsx @@ -1,4 +1,4 @@ -import { useParams } from "react-router-dom" +import { useParams, useSearchParams } from "react-router-dom" import { RouteFocusModal } from "../../../components/modals" import { useOrder } from "../../../hooks/api/orders" @@ -6,6 +6,8 @@ import { OrderCreateFulfillmentForm } from "./components/order-create-fulfillmen export function OrderCreateFulfillment() { const { id } = useParams() + const [searchParams] = useSearchParams() + const requiresShipping = searchParams.get("requires_shipping") === "true" const { order, isLoading, isError, error } = useOrder(id!, { fields: "currency_code,*items,*items.variant,*shipping_address", @@ -19,7 +21,12 @@ export function OrderCreateFulfillment() { return ( - {ready && } + {ready && ( + + )} ) } 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 b9a9c5076901e..d124474221009 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 @@ -2,6 +2,7 @@ import { Buildings, XCircle } from "@medusajs/icons" import { AdminOrder, AdminOrderFulfillment, + AdminOrderLineItem, HttpTypes, OrderLineItemDTO, } from "@medusajs/types" @@ -108,25 +109,64 @@ const UnfulfilledItem = ({ } 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 - const unfulfilledItems = order.items!.filter( - (i) => i.detail.fulfilled_quantity < i.quantity + const unfulfilledItemsWithShipping = order.items!.filter( + (i) => i.requires_shipping && i.detail.fulfilled_quantity < i.quantity + ) + + const unfulfilledItemsWithoutShipping = order.items!.filter( + (i) => !i.requires_shipping && i.detail.fulfilled_quantity < i.quantity ) - if (!unfulfilledItems.length) { - return null - } + + return ( + <> + {!!unfulfilledItemsWithShipping.length && ( + + )} + + {!!unfulfilledItemsWithoutShipping.length && ( + + )} + + ) +} + +const UnfulfilledItemDisplay = ({ + order, + unfulfilledItems, + requiresShipping = false, +}: { + order: AdminOrder + unfulfilledItems: AdminOrderLineItem[] + requiresShipping: boolean +}) => { + const { t } = useTranslation() return (
{t("orders.fulfillment.unfulfilledItems")} +
+ {requiresShipping && ( + + {t("orders.fulfillment.requiresShipping")} + + )} + {t("orders.fulfillment.awaitingFulfillmentBadge")} + { { label: t("orders.fulfillment.fulfillItems"), icon: , - to: `/orders/${order.id}/fulfillment`, + to: `/orders/${order.id}/fulfillment?requires_shipping=${requiresShipping}`, }, ], }, @@ -143,7 +183,7 @@ const UnfulfilledItemBreakdown = ({ order }: { order: AdminOrder }) => {
- {unfulfilledItems.map((item) => ( + {unfulfilledItems.map((item: AdminOrderLineItem) => ( unitPrice: BigNumberInput isTaxInclusive?: boolean - variant: ProductVariantDTO + variant: ProductVariantDTO & { + inventory_items: { inventory: InventoryItemDTO }[] + } taxLines?: CreateOrderLineItemTaxLineDTO[] adjustments?: CreateOrderAdjustmentDTO[] cartId?: string @@ -35,6 +39,19 @@ export function prepareLineItemData(data: Input) { 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 = { quantity, title: variant.title ?? item?.title, @@ -58,7 +75,7 @@ export function prepareLineItemData(data: Input) { variant_option_values: item?.variant_option_values, is_discountable: variant.product.discountable ?? item?.is_discountable, - requires_shipping: variant.requires_shipping ?? item?.requires_shipping, + requires_shipping: requiresShipping, unit_price: unitPrice, is_tax_inclusive: !!isTaxInclusive, diff --git a/packages/core/core-flows/src/order/utils/fields.ts b/packages/core/core-flows/src/order/utils/fields.ts index 42f1e3a3a841b..2c74d425f5ad3 100644 --- a/packages/core/core-flows/src/order/utils/fields.ts +++ b/packages/core/core-flows/src/order/utils/fields.ts @@ -16,6 +16,7 @@ export const productVariantsFields = [ "calculated_price.calculated_amount", "inventory_items.inventory_item_id", "inventory_items.required_quantity", + "inventory_items.inventory.requires_shipping", "inventory_items.inventory.location_levels.stock_locations.id", "inventory_items.inventory.location_levels.stock_locations.name", "inventory_items.inventory.location_levels.stock_locations.sales_channels.id", diff --git a/packages/core/core-flows/src/order/utils/order-validation.ts b/packages/core/core-flows/src/order/utils/order-validation.ts index c1c74dd1c0bc8..dcc82492cc16e 100644 --- a/packages/core/core-flows/src/order/utils/order-validation.ts +++ b/packages/core/core-flows/src/order/utils/order-validation.ts @@ -1,6 +1,7 @@ import { OrderChangeDTO, OrderDTO, + OrderLineItemDTO, OrderWorkflow, ReturnDTO, } from "@medusajs/types" @@ -41,6 +42,37 @@ export function throwIfItemsDoesNotExistsInOrder({ } } +export function throwIfItemsAreNotGroupedByShippingRequirement({ + order, + inputItems, +}: { + order: Pick + inputItems: OrderWorkflow.CreateOrderFulfillmentWorkflowInput["items"] +}) { + const itemsWithShipping: string[] = [] + const itemsWithoutShipping: string[] = [] + const orderItemsMap = new Map( + (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( obj: unknown & { id: string; canceled_at?: any }, type: string diff --git a/packages/core/core-flows/src/order/workflows/create-fulfillment.ts b/packages/core/core-flows/src/order/workflows/create-fulfillment.ts index 644df86bb23c1..d92de9271d3a1 100644 --- a/packages/core/core-flows/src/order/workflows/create-fulfillment.ts +++ b/packages/core/core-flows/src/order/workflows/create-fulfillment.ts @@ -26,6 +26,7 @@ import { } from "../../reservation" import { registerOrderFulfillmentStep } from "../steps" import { + throwIfItemsAreNotGroupedByShippingRequirement, throwIfItemsDoesNotExistsInOrder, throwIfOrderIsCancelled, } from "../utils/order-validation" @@ -35,18 +36,16 @@ import { */ export const createFulfillmentValidateOrder = createStep( "create-fulfillment-validate-order", - ( - { - order, - inputItems, - }: { - order: OrderDTO - inputItems: OrderWorkflow.CreateOrderFulfillmentWorkflowInput["items"] - }, - context - ) => { + ({ + order, + inputItems, + }: { + order: OrderDTO + inputItems: OrderWorkflow.CreateOrderFulfillmentWorkflowInput["items"] + }) => { throwIfOrderIsCancelled({ order }) throwIfItemsDoesNotExistsInOrder({ order, inputItems }) + throwIfItemsAreNotGroupedByShippingRequirement({ order, inputItems }) } ) @@ -91,16 +90,29 @@ function prepareFulfillmentData({ reservations: ReservationItemDTO[] itemsList?: OrderLineItemDTO[] }) { - const inputItems = input.items + const fulfillableItems = input.items const orderItemsMap = new Map["items"][0]>( (itemsList ?? order.items)!.map((i) => [i.id, i]) ) + const reservationItemMap = new Map( 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 reservation = reservationItemMap.get(i.id)! + return { line_item_id: i.id, inventory_item_id: reservation?.inventory_item_id, @@ -134,6 +146,7 @@ function prepareFulfillmentData({ shipping_option_id: shippingOption.id, data: shippingMethod.data, items: fulfillmentItems, + requires_shipping: someItemsRequireShipping, labels: input.labels ?? [], delivery_address: shippingAddress as any, packed_at: new Date(), diff --git a/packages/core/core-flows/src/order/workflows/create-orders.ts b/packages/core/core-flows/src/order/workflows/create-orders.ts index b7deec87895eb..51ce34db222f8 100644 --- a/packages/core/core-flows/src/order/workflows/create-orders.ts +++ b/packages/core/core-flows/src/order/workflows/create-orders.ts @@ -8,7 +8,6 @@ import { parallelize, transform, } from "@medusajs/workflows-sdk" -import { useRemoteQueryStep } from "../../common" import { findOneOrAnyRegionStep } from "../../cart/steps/find-one-or-any-region" import { findOrCreateCustomerStep } from "../../cart/steps/find-or-create-customer" 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 { prepareLineItemData } from "../../cart/utils/prepare-line-item-data" import { confirmVariantInventoryWorkflow } from "../../cart/workflows/confirm-variant-inventory" +import { useRemoteQueryStep } from "../../common" import { createOrdersStep } from "../steps" import { productVariantsFields } from "../utils/fields" import { prepareCustomLineItemData } from "../utils/prepare-custom-line-item-data" diff --git a/packages/core/types/src/fulfillment/common/fulfillment.ts b/packages/core/types/src/fulfillment/common/fulfillment.ts index 48670f00fd951..88f3cf55957c0 100644 --- a/packages/core/types/src/fulfillment/common/fulfillment.ts +++ b/packages/core/types/src/fulfillment/common/fulfillment.ts @@ -75,6 +75,11 @@ export interface FulfillmentDTO { */ shipping_option: ShippingOptionDTO | null + /** + * Flag to indidcate whether shipping is required + */ + requires_shipping: boolean + /** * The associated fulfillment provider. */ diff --git a/packages/core/types/src/fulfillment/mutations/fulfillment.ts b/packages/core/types/src/fulfillment/mutations/fulfillment.ts index ceed876c7e431..243b426dc75a8 100644 --- a/packages/core/types/src/fulfillment/mutations/fulfillment.ts +++ b/packages/core/types/src/fulfillment/mutations/fulfillment.ts @@ -52,6 +52,11 @@ export interface CreateFulfillmentDTO { */ shipping_option_id?: string | null + /** + * Flag to indicate whether shipping is required + */ + requires_shipping?: boolean + /** * Holds custom data in key-value pairs. */ diff --git a/packages/core/types/src/http/order/admin/entities.ts b/packages/core/types/src/http/order/admin/entities.ts index 6f0b35cc5b24b..3c19c8751016b 100644 --- a/packages/core/types/src/http/order/admin/entities.ts +++ b/packages/core/types/src/http/order/admin/entities.ts @@ -20,6 +20,7 @@ export interface AdminOrder extends BaseOrder { customer?: AdminCustomer shipping_address?: AdminOrderAddress | null billing_address?: AdminOrderAddress | null + items: AdminOrderLineItem[] } export interface AdminOrderLineItem extends BaseOrderLineItem { diff --git a/packages/core/types/src/http/order/common.ts b/packages/core/types/src/http/order/common.ts index a27deb4c933cf..932b15e6fc20e 100644 --- a/packages/core/types/src/http/order/common.ts +++ b/packages/core/types/src/http/order/common.ts @@ -240,6 +240,7 @@ export interface BaseOrderFulfillment { shipped_at: Date | null delivered_at: Date | null canceled_at: Date | null + requires_shipping: boolean data: Record | null provider_id: string shipping_option_id: string | null diff --git a/packages/medusa/src/api/admin/orders/[id]/fulfillments/route.ts b/packages/medusa/src/api/admin/orders/[id]/fulfillments/route.ts index 3019276b754ef..3ac9c52ee0038 100644 --- a/packages/medusa/src/api/admin/orders/[id]/fulfillments/route.ts +++ b/packages/medusa/src/api/admin/orders/[id]/fulfillments/route.ts @@ -1,4 +1,5 @@ import { createOrderFulfillmentWorkflow } from "@medusajs/core-flows" +import { AdditionalData, HttpTypes } from "@medusajs/types" import { ContainerRegistrationKeys, remoteQueryObjectFromString, @@ -8,7 +9,6 @@ import { MedusaResponse, } from "../../../../../types/routing" import { AdminOrderCreateFulfillmentType } from "../../validators" -import { AdditionalData, HttpTypes } from "@medusajs/types" export const POST = async ( req: AuthenticatedMedusaRequest< @@ -18,20 +18,16 @@ export const POST = async ( ) => { const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) - const variables = { id: req.params.id } - - const input = { - ...req.validatedBody, - order_id: req.params.id, - } - await createOrderFulfillmentWorkflow(req.scope).run({ - input, + input: { + ...req.validatedBody, + order_id: req.params.id, + }, }) const queryObject = remoteQueryObjectFromString({ entryPoint: "order", - variables, + variables: { id: req.params.id }, fields: req.remoteQueryConfig.fields, }) diff --git a/packages/medusa/src/api/store/carts/query-config.ts b/packages/medusa/src/api/store/carts/query-config.ts index ce7d77218369e..3753abd5e66f6 100644 --- a/packages/medusa/src/api/store/carts/query-config.ts +++ b/packages/medusa/src/api/store/carts/query-config.ts @@ -47,6 +47,7 @@ export const defaultStoreCartFields = [ "items.variant_sku", "items.variant_barcode", "items.variant_title", + "items.requires_shipping", "items.metadata", "items.created_at", "items.updated_at", diff --git a/packages/modules/fulfillment/src/migrations/.snapshot-medusa-fulfillment.json b/packages/modules/fulfillment/src/migrations/.snapshot-medusa-fulfillment.json index 01970254492e7..d3e275ed93473 100644 --- a/packages/modules/fulfillment/src/migrations/.snapshot-medusa-fulfillment.json +++ b/packages/modules/fulfillment/src/migrations/.snapshot-medusa-fulfillment.json @@ -1359,6 +1359,16 @@ "nullable": true, "mappedType": "text" }, + "requires_shipping": { + "name": "requires_shipping", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "true", + "mappedType": "boolean" + }, "created_at": { "name": "created_at", "type": "timestamptz", diff --git a/packages/modules/fulfillment/src/migrations/Migration20240917161003.ts b/packages/modules/fulfillment/src/migrations/Migration20240917161003.ts new file mode 100644 index 0000000000000..984022b5b9675 --- /dev/null +++ b/packages/modules/fulfillment/src/migrations/Migration20240917161003.ts @@ -0,0 +1,15 @@ +import { Migration } from "@mikro-orm/migrations" + +export class Migration20240917161003 extends Migration { + async up(): Promise { + this.addSql( + 'alter table if exists "fulfillment" add column if not exists "requires_shipping" boolean not null default true;' + ) + } + + async down(): Promise { + this.addSql( + 'alter table if exists "fulfillment" drop column if exists "requires_shipping";' + ) + } +} diff --git a/packages/modules/fulfillment/src/models/fulfillment.ts b/packages/modules/fulfillment/src/models/fulfillment.ts index 3a11ed5d04ecc..af009e9df356a 100644 --- a/packages/modules/fulfillment/src/models/fulfillment.ts +++ b/packages/modules/fulfillment/src/models/fulfillment.ts @@ -135,6 +135,9 @@ export default class Fulfillment { }) delivery_address!: Rel + @Property({ columnType: "boolean", default: true }) + requires_shipping: boolean = true + @OneToMany(() => FulfillmentItem, (item) => item.fulfillment, { cascade: [Cascade.PERSIST, "soft-remove"] as any, orphanRemoval: true,