feat(core-flows, dashboard, link-modules,medusa, types, utils): fulfillment shipping changes (#10902)

**What**
- product <> shipping profile link
- create and update product workflows/endpoints accepts shipping profile
- pass shipping option id when creating fulfillment to allow overriding customer selected SO
- validate shipping profile delete
- dashboard
  - set shipping profile on product create
  - manage shipping profile for a product
  - **update the create fulfillment form**
- other
  - fix create product form infinite rerenders
 
---

CLOSES CMRC-831 CMRC-834 CMRC-836 CMRC-837 CMRC-838 CMRC-857 TRI-761
This commit is contained in:
Frane Polić
2025-01-27 12:00:20 +00:00
committed by GitHub
parent 3e81962503
commit 864d772e34
78 changed files with 3529 additions and 794 deletions
@@ -58,14 +58,14 @@ export type CreateFulfillmentValidateOrderStepInput = {
* This step validates that a fulfillment can be created for an order. If the order
* is canceled, the items don't exist in the order, or the items aren't grouped by
* shipping requirement, the step throws an error.
*
*
* :::note
*
*
* You can retrieve an order's details using [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query),
* or [useQueryGraphStep](https://docs.medusajs.com/resources/references/medusa-workflows/steps/useQueryGraphStep).
*
*
* :::
*
*
* @example
* const data = createFulfillmentValidateOrder({
* order: {
@@ -82,10 +82,7 @@ export type CreateFulfillmentValidateOrderStepInput = {
*/
export const createFulfillmentValidateOrder = createStep(
"create-fulfillment-validate-order",
({
order,
inputItems,
}: CreateFulfillmentValidateOrderStepInput) => {
({ order, inputItems }: CreateFulfillmentValidateOrderStepInput) => {
throwIfOrderIsCancelled({ order })
throwIfItemsDoesNotExistsInOrder({ order, inputItems })
throwIfItemsAreNotGroupedByShippingRequirement({ order, inputItems })
@@ -128,6 +125,7 @@ function prepareFulfillmentData({
id: string
provider_id: string
service_zone: { fulfillment_set: { location?: { id: string } } }
shipping_profile_id: string
}
shippingMethod: { data?: Record<string, unknown> | null }
reservations: ReservationItemDTO[]
@@ -156,6 +154,18 @@ function prepareFulfillmentData({
const orderItem = orderItemsMap.get(i.id)!
const reservation = reservationItemMap.get(i.id)!
if (
orderItem.requires_shipping &&
(orderItem as any).variant?.product &&
(orderItem as any).variant?.product.shipping_profile?.id !==
shippingOption.shipping_profile_id
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Shipping profile ${shippingOption.shipping_profile_id} does not match the shipping profile of the order item ${orderItem.id}`
)
}
return {
line_item_id: i.id,
inventory_item_id: reservation?.inventory_item_id,
@@ -273,17 +283,18 @@ function prepareInventoryUpdate({
/**
* The details of the fulfillment to create, along with custom data that's passed to the workflow's hooks.
*/
export type CreateOrderFulfillmentWorkflowInput = OrderWorkflow.CreateOrderFulfillmentWorkflowInput & AdditionalData
export type CreateOrderFulfillmentWorkflowInput =
OrderWorkflow.CreateOrderFulfillmentWorkflowInput & AdditionalData
export const createOrderFulfillmentWorkflowId = "create-order-fulfillment"
/**
* This workflow creates a fulfillment for an order. It's used by the [Create Order Fulfillment Admin API Route](https://docs.medusajs.com/api/admin#orders_postordersidfulfillments).
*
* This workflow has a hook that allows you to perform custom actions on the created fulfillment. For example, you can pass under `additional_data` custom data that
*
* This workflow has a hook that allows you to perform custom actions on the created fulfillment. For example, you can pass under `additional_data` custom data that
* allows you to create custom data models linked to the fulfillment.
*
*
* You can also use this workflow within your customizations or your own custom workflows, allowing you to wrap custom logic around creating a fulfillment.
*
*
* @example
* const { result } = await createOrderFulfillmentWorkflow(container)
* .run({
@@ -300,18 +311,16 @@ export const createOrderFulfillmentWorkflowId = "create-order-fulfillment"
* }
* }
* })
*
*
* @summary
*
*
* Creates a fulfillment for an order.
*
*
* @property hooks.fulfillmentCreated - This hook is executed after the fulfillment is created. You can consume this hook to perform custom actions on the created fulfillment.
*/
export const createOrderFulfillmentWorkflow = createWorkflow(
createOrderFulfillmentWorkflowId,
(
input: WorkflowData<CreateOrderFulfillmentWorkflowInput>
) => {
(input: WorkflowData<CreateOrderFulfillmentWorkflowInput>) => {
const order: OrderDTO = useRemoteQueryStep({
entry_point: "orders",
fields: [
@@ -323,12 +332,14 @@ export const createOrderFulfillmentWorkflow = createWorkflow(
"items.variant.manage_inventory",
"items.variant.allow_backorder",
"items.variant.product.id",
"items.variant.product.shipping_profile.id",
"items.variant.weight",
"items.variant.length",
"items.variant.height",
"items.variant.width",
"items.variant.material",
"shipping_address.*",
"shipping_methods.id",
"shipping_methods.shipping_option_id",
"shipping_methods.data",
],
@@ -346,17 +357,29 @@ export const createOrderFulfillmentWorkflow = createWorkflow(
}, {})
})
const shippingMethod = transform(order, (data) => {
return { data: data.shipping_methods?.[0]?.data }
const shippingOptionId = transform({ order, input }, (data) => {
return (
data.input.shipping_option_id ??
data.order.shipping_methods?.[0]?.shipping_option_id
)
})
const shippingOptionId = transform(order, (data) => {
return data.shipping_methods?.[0]?.shipping_option_id
const shippingMethod = transform({ order, shippingOptionId }, (data) => {
return {
data: data.order.shipping_methods?.find(
(sm) => sm.shipping_option_id === data.shippingOptionId
)?.data,
}
})
const shippingOption = useRemoteQueryStep({
entry_point: "shipping_options",
fields: ["id", "provider_id", "service_zone.fulfillment_set.location.id"],
fields: [
"id",
"provider_id",
"service_zone.fulfillment_set.location.id",
"shipping_profile_id",
],
variables: {
id: shippingOptionId,
},
@@ -136,6 +136,11 @@ const normalizeProductForImport = (
return
}
if (normalizedKey.startsWith("shipping_profile_id")) {
response["shipping_profile_id"] = normalizedValue
return
}
if (normalizedKey.startsWith("product_category_")) {
response["categories"] = [
...(response["categories"] || []),
@@ -1,4 +1,8 @@
import { ProductTypes, SalesChannelTypes } from "@medusajs/framework/types"
import {
ProductTypes,
SalesChannelTypes,
ShippingProfileDTO,
} from "@medusajs/framework/types"
import { MedusaError } from "@medusajs/framework/utils"
const basicFieldsToOmit = [
@@ -32,6 +36,7 @@ export const normalizeV1Products = (
productTypes: ProductTypes.ProductTypeDTO[]
productCollections: ProductTypes.ProductCollectionDTO[]
salesChannels: SalesChannelTypes.SalesChannelDTO[]
shippingProfiles: ShippingProfileDTO[]
}
): object[] => {
const productTypesMap = new Map(
@@ -43,6 +48,9 @@ export const normalizeV1Products = (
const salesChannelsMap = new Map(
supportingData.salesChannels.map((sc) => [sc.name, sc.id])
)
const shippingProfilesIds = new Set(
supportingData.shippingProfiles.map((sp) => sp.id)
)
return rawProducts.map((product) => {
let finalRes = {
@@ -140,6 +148,21 @@ export const normalizeV1Products = (
}
}
if (key.startsWith("Shipping Profile Id")) {
if (!value) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Shipping Profile Id is required when importing products"
)
}
if (!shippingProfilesIds.has(value)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Shipping profile: '${value}' does not exist`
)
}
}
if (
key.startsWith("Product Category") &&
(key.endsWith("Handle") ||
@@ -1,4 +1,5 @@
import {
IFulfillmentModuleService,
IProductModuleService,
IRegionModuleService,
ISalesChannelModuleService,
@@ -16,9 +17,9 @@ export type ParseProductCsvStepInput = string
export const parseProductCsvStepId = "parse-product-csv"
/**
* This step parses a CSV file holding products to import, returning the products as
* This step parses a CSV file holding products to import, returning the products as
* objects that can be imported.
*
*
* @example
* const data = parseProductCsvStep("products.csv")
*/
@@ -35,20 +36,25 @@ export const parseProductCsvStep = createStep(
Modules.SALES_CHANNEL
)
const fulfillmentService = container.resolve<IFulfillmentModuleService>(
Modules.FULFILLMENT
)
const csvProducts = convertCsvToJson(fileContent)
const [productTypes, productCollections, salesChannels] = await Promise.all(
[
const [productTypes, productCollections, salesChannels, shippingProfiles] =
await Promise.all([
productService.listProductTypes({}, {}),
productService.listProductCollections({}, {}),
salesChannelService.listSalesChannels({}, {}),
]
)
fulfillmentService.listShippingProfiles({}, {}),
])
const v1Normalized = normalizeV1Products(csvProducts, {
productTypes,
productCollections,
salesChannels,
shippingProfiles,
})
// We use the handle to group products and variants correctly.
@@ -1,6 +1,7 @@
import {
AdditionalData,
CreateProductWorkflowInputDTO,
LinkDefinition,
PricingTypes,
ProductTypes,
} from "@medusajs/framework/types"
@@ -8,6 +9,7 @@ import {
ProductWorkflowEvents,
isPresent,
MedusaError,
Modules,
} from "@medusajs/framework/utils"
import {
WorkflowData,
@@ -17,7 +19,11 @@ import {
transform,
createStep,
} from "@medusajs/framework/workflows-sdk"
import { emitEventStep } from "../../common"
import {
createRemoteLinkStep,
emitEventStep,
useQueryGraphStep,
} from "../../common"
import { associateProductsWithSalesChannelsStep } from "../../sales-channel"
import { createProductsStep } from "../steps/create-products"
import { createProductVariantsWorkflow } from "./create-product-variants"
@@ -29,14 +35,19 @@ export interface ValidateProductInputStepInput {
/**
* The products to validate.
*/
products: CreateProductWorkflowInputDTO[]
products: Omit<CreateProductWorkflowInputDTO, "sales_channels">[]
/**
* The shipping profiles to validate.
*/
shippingProfiles: { id: string }[]
}
const validateProductInputStepId = "validate-product-input"
/**
* This step validates that all provided products have options.
* If a product is missing options, an error is thrown.
*
*
* @example
* const data = validateProductInputStep({
* products: [
@@ -71,7 +82,7 @@ const validateProductInputStepId = "validate-product-input"
export const validateProductInputStep = createStep(
validateProductInputStepId,
async (data: ValidateProductInputStepInput) => {
const { products } = data
const { products, shippingProfiles } = data
const missingOptionsProductTitles = products
.filter((product) => !product.options?.length)
@@ -85,6 +96,25 @@ export const validateProductInputStep = createStep(
)}].`
)
}
const existingProfileIds = new Set(shippingProfiles.map((p) => p.id))
const missingShippingProfileProductTitles = products
.filter(
(product) =>
!product.shipping_profile_id ||
!existingProfileIds.has(product.shipping_profile_id)
)
.map((product) => product.title)
if (missingShippingProfileProductTitles.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Shipping profile is not provided for: [${missingShippingProfileProductTitles.join(
", "
)}].`
)
}
}
)
@@ -102,11 +132,11 @@ export const createProductsWorkflowId = "create-products"
/**
* This workflow creates one or more products. It's used by the [Create Product Admin API Route](https://docs.medusajs.com/api/admin#products_postproducts).
* It can also be useful to you when creating [seed scripts](https://docs.medusajs.com/learn/fundamentals/custom-cli-scripts/seed-data), for example.
*
*
* This workflow has a hook that allows you to perform custom actions on the created products. You can see an example in [this guide](https://docs.medusajs.com/resources/commerce-modules/product/extend).
*
*
* You can also use this workflow within your customizations or your own custom workflows, allowing you to wrap custom logic around product creation.
*
*
* @example
* const { result } = await createProductsWorkflow(container)
* .run({
@@ -143,26 +173,45 @@ export const createProductsWorkflowId = "create-products"
* }
* }
* })
*
*
* @summary
*
*
* Create one or more products with options and variants.
*
*
* @property hooks.productCreated - This hook is executed after the products are created. You can consume this hook to perform custom actions on the created products.
*/
export const createProductsWorkflow = createWorkflow(
createProductsWorkflowId,
(input: WorkflowData<CreateProductsWorkflowInput>) => {
// Passing prices to the product module will fail, we want to keep them for after the product is created.
const productWithoutExternalRelations = transform({ input }, (data) =>
data.input.products.map((p) => ({
...p,
sales_channels: undefined,
variants: undefined,
}))
)
const { products: productWithoutExternalRelations, shippingPorfileIds } =
transform({ input }, (data) => {
const shippingPorfileIds: string[] = []
const productsData = data.input.products.map((p) => {
if (p.shipping_profile_id) {
shippingPorfileIds.push(p.shipping_profile_id)
}
validateProductInputStep({ products: productWithoutExternalRelations })
return {
...p,
sales_channels: undefined,
shipping_profile_id: undefined,
variants: undefined,
}
})
return { products: productsData, shippingPorfileIds }
})
const { data: shippingProfiles } = useQueryGraphStep({
entity: "shipping_profile",
fields: ["id"],
filters: {
id: shippingPorfileIds,
},
})
validateProductInputStep({ products: input.products, shippingProfiles })
const createdProducts = createProductsStep(productWithoutExternalRelations)
@@ -182,6 +231,24 @@ export const createProductsWorkflow = createWorkflow(
associateProductsWithSalesChannelsStep({ links: salesChannelLinks })
const shippingProfileLinks = transform(
{ input, createdProducts },
(data) => {
return data.createdProducts.map((createdProduct, i) => {
return {
[Modules.PRODUCT]: {
product_id: createdProduct.id,
},
[Modules.FULFILLMENT]: {
shipping_profile_id: data.input.products[i].shipping_profile_id,
},
}
})
}
)
createRemoteLinkStep(shippingProfileLinks as LinkDefinition[])
const variantsInput = transform({ input, createdProducts }, (data) => {
// TODO: Move this to a unified place for all product workflow types
const productVariants: (ProductTypes.CreateProductVariantDTO & {
@@ -47,6 +47,10 @@ export type UpdateProductsWorkflowInputSelector = {
* The variants to update.
*/
variants?: UpdateProductVariantWorkflowInputDTO[]
/**
* The shipping profile to set.
*/
shipping_profile_id?: string
}
} & AdditionalData
@@ -66,6 +70,10 @@ export type UpdateProductsWorkflowInputProducts = {
* The variants to update.
*/
variants?: UpdateProductVariantWorkflowInputDTO[]
/**
* The shipping profile to set.
*/
shipping_profile_id?: string
})[]
} & AdditionalData
@@ -90,6 +98,7 @@ function prepareUpdateProductInput({
products: input.products.map((p) => ({
...p,
sales_channels: undefined,
shipping_profile_id: undefined,
variants: p.variants?.map((v) => ({
...v,
prices: undefined,
@@ -103,6 +112,7 @@ function prepareUpdateProductInput({
update: {
...input.update,
sales_channels: undefined,
shipping_profile_id: undefined,
variants: input.update?.variants?.map((v) => ({
...v,
prices: undefined,
@@ -173,6 +183,44 @@ function prepareSalesChannelLinks({
return []
}
function prepareShippingProfileLinks({
input,
updatedProducts,
}: {
updatedProducts: ProductTypes.ProductDTO[]
input: UpdateProductWorkflowInput
}): Record<string, Record<string, any>>[] {
if ("products" in input) {
if (!input.products.length) {
return []
}
return input.products
.filter((p) => p.shipping_profile_id)
.map((p) => ({
[Modules.PRODUCT]: {
product_id: p.id,
},
[Modules.FULFILLMENT]: {
shipping_profile_id: p.shipping_profile_id,
},
}))
}
if (input.selector && input.update?.shipping_profile_id) {
return updatedProducts.map((p) => ({
[Modules.PRODUCT]: {
product_id: p.id,
},
[Modules.FULFILLMENT]: {
shipping_profile_id: input.update.shipping_profile_id,
},
}))
}
return []
}
function prepareVariantPrices({
input,
updatedProducts,
@@ -243,18 +291,42 @@ function prepareToDeleteSalesChannelLinks({
}))
}
function prepareToDeleteShippingProfileLinks({
currentShippingProfileLinks,
}: {
currentShippingProfileLinks: {
product_id: string
shipping_profile_id: string
}[]
}) {
if (!currentShippingProfileLinks.length) {
return []
}
return currentShippingProfileLinks.map(
({ product_id, shipping_profile_id }) => ({
[Modules.PRODUCT]: {
product_id,
},
[Modules.FULFILLMENT]: {
shipping_profile_id,
},
})
)
}
export const updateProductsWorkflowId = "update-products"
/**
* This workflow updates one or more products. It's used by the [Update Product Admin API Route](https://docs.medusajs.com/api/admin#products_postproductsid).
*
* This workflow has a hook that allows you to perform custom actions on the updated products. For example, you can pass under `additional_data` custom data that
*
* This workflow has a hook that allows you to perform custom actions on the updated products. For example, you can pass under `additional_data` custom data that
* allows you to update custom data models linked to the products.
*
*
* You can also use this workflow within your customizations or your own custom workflows, allowing you to wrap custom logic around product update.
*
*
* @example
* To update products by their IDs:
*
*
* ```ts
* const { result } = await updateProductsWorkflow(container)
* .run({
@@ -282,9 +354,9 @@ export const updateProductsWorkflowId = "update-products"
* }
* })
* ```
*
*
* You can also update products by a selector:
*
*
* ```ts
* const { result } = await updateProductsWorkflow(container)
* .run({
@@ -301,11 +373,11 @@ export const updateProductsWorkflowId = "update-products"
* }
* })
* ```
*
*
* @summary
*
*
* Update one or more products with options and variants.
*
*
* @property hooks.productsUpdated - This hook is executed after the products are updated. You can consume this hook to perform custom actions on the updated products.
*/
export const updateProductsWorkflow = createWorkflow(
@@ -345,11 +417,20 @@ export const updateProductsWorkflow = createWorkflow(
const toUpdateInput = transform({ input }, prepareUpdateProductInput)
const updatedProducts = updateProductsStep(toUpdateInput)
const updatedPorductIds = transform({ updatedProducts }, (data) => {
return data.updatedProducts.map((p) => p.id)
})
const salesChannelLinks = transform(
{ input, updatedProducts },
prepareSalesChannelLinks
)
const shippingProfileLinks = transform(
{ input, updatedProducts },
prepareShippingProfileLinks
)
const variantPrices = transform(
{ input, updatedProducts },
prepareVariantPrices
@@ -366,16 +447,33 @@ export const updateProductsWorkflow = createWorkflow(
variables: { filters: { product_id: productsWithSalesChannels } },
}).config({ name: "get-current-sales-channel-links-step" })
const currentShippingProfileLinks = useRemoteQueryStep({
entry_point: "product_shipping_profile",
fields: ["product_id", "shipping_profile_id"],
variables: { filters: { product_id: updatedPorductIds } },
}).config({ name: "get-current-shipping-profile-links-step" })
const toDeleteSalesChannelLinks = transform(
{ currentSalesChannelLinks },
prepareToDeleteSalesChannelLinks
)
const toDeleteShippingProfileLinks = transform(
{ currentShippingProfileLinks },
prepareToDeleteShippingProfileLinks
)
upsertVariantPricesWorkflow.runAsStep({
input: { variantPrices, previousVariantIds },
})
dismissRemoteLinkStep(toDeleteSalesChannelLinks)
dismissRemoteLinkStep(toDeleteSalesChannelLinks).config({
name: "delete-sales-channel-links-step",
})
dismissRemoteLinkStep(toDeleteShippingProfileLinks).config({
name: "delete-shipping-profile-links-step",
})
const productIdEvents = transform(
{ updatedProducts },
@@ -387,7 +485,12 @@ export const updateProductsWorkflow = createWorkflow(
)
parallelize(
createRemoteLinkStep(salesChannelLinks),
createRemoteLinkStep(salesChannelLinks).config({
name: "create-sales-channel-links-step",
}),
createRemoteLinkStep(shippingProfileLinks).config({
name: "create-shipping-profile-links-step",
}),
emitEventStep({
eventName: ProductWorkflowEvents.UPDATED,
data: productIdEvents,
@@ -1,8 +1,31 @@
import { createWorkflow, WorkflowData } from "@medusajs/framework/workflows-sdk"
import {
createStep,
createWorkflow,
WorkflowData,
} from "@medusajs/framework/workflows-sdk"
import { MedusaError, Modules } from "@medusajs/framework/utils"
import { deleteShippingProfilesStep } from "../steps"
import { removeRemoteLinkStep } from "../../common"
import { Modules } from "@medusajs/framework/utils"
import { removeRemoteLinkStep, useQueryGraphStep } from "../../common"
/**
* This step validates that the shipping profiles to delete are not linked to any products.
*/
const validateStepShippingProfileDelete = createStep(
"validate-step-shipping-profile-delete",
(data: { links: { product_id: string; shipping_profile_id: string }[] }) => {
const { links } = data
if (links.length > 0) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Cannot delete following shipping profiles because they are linked to products: ${links
.map((l) => l.product_id)
.join(", ")}`
)
}
}
)
/**
* The data to delete shipping profiles.
@@ -19,10 +42,11 @@ export const deleteShippingProfileWorkflowId =
/**
* This workflow deletes one or more shipping profiles. It's used by the
* [Delete Shipping Profile Admin API Route](https://docs.medusajs.com/api/admin#shipping-profiles_deleteshippingprofilesid).
*
* Shipping profiles that are linked to products cannot be deleted.
*
* You can use this workflow within your customizations or your own custom workflows, allowing you to
* delete shipping profiles within your custom flows.
*
*
* @example
* const { result } = await deleteShippingProfileWorkflow(container)
* .run({
@@ -30,14 +54,24 @@ export const deleteShippingProfileWorkflowId =
* ids: ["sp_123"]
* }
* })
*
*
* @summary
*
*
* Delete shipping profiles.
*/
export const deleteShippingProfileWorkflow = createWorkflow(
deleteShippingProfileWorkflowId,
(input: WorkflowData<DeleteShippingProfilesWorkflowInput>) => {
const currentShippingProfileLinks = useQueryGraphStep({
entity: "product_shipping_profile",
fields: ["product_id", "shipping_profile_id"],
filters: { shipping_profile_id: input.ids },
})
validateStepShippingProfileDelete({
links: currentShippingProfileLinks.data,
})
deleteShippingProfilesStep(input.ids)
removeRemoteLinkStep({