feat(fulfillment): shipping options context field (#7169)

This commit is contained in:
Carlos R. L. Rodrigues
2024-05-02 09:59:14 -03:00
committed by GitHub
parent 25b0ccc60a
commit 9d3f495314
22 changed files with 973 additions and 371 deletions

View File

@@ -0,0 +1,10 @@
---
"@medusajs/link-modules": patch
"@medusajs/fulfillment": patch
"@medusajs/core-flows": patch
"@medusajs/pricing": patch
"@medusajs/utils": patch
"@medusajs/types": patch
---
Fulfillment - shipping options with context

View File

@@ -601,7 +601,7 @@ medusaIntegrationTestRunner({
])
cart = await cartModuleService.retrieve(cart.id, {
select: ["id", "region_id", "currency_code"],
select: ["id", "region_id", "currency_code", "sales_channel_id"],
})
await addToCartWorkflow(appContainer).run({
@@ -707,7 +707,7 @@ medusaIntegrationTestRunner({
expect(errors).toEqual([
{
action: "get-variant-price-sets",
action: "confirm-inventory-step",
handlerType: "invoke",
error: expect.objectContaining({
message: `Variants with IDs ${product.variants[0].id} do not have a price`,
@@ -736,10 +736,11 @@ medusaIntegrationTestRunner({
expect(errors).toEqual([
{
action: "validate-variants-exist",
action: "use-remote-query",
handlerType: "invoke",
error: expect.objectContaining({
message: `Variants with IDs prva_foo do not exist`,
// TODO: Implement error message handler for Remote Query throw_if_key_not_found
message: `productService id not found: prva_foo`,
}),
},
])
@@ -1280,7 +1281,7 @@ medusaIntegrationTestRunner({
expect(updatedPaymentCollection).toEqual(
expect.objectContaining({
id: paymentCollection.id,
amount: 4242,
amount: 5000,
})
)
@@ -1879,13 +1880,9 @@ medusaIntegrationTestRunner({
})
expect(errors).toEqual([
{
action: "get-shipping-option-price-sets",
error: expect.objectContaining({
message: `Shipping options with IDs ${shippingOption.id} do not have a price`,
}),
handlerType: "invoke",
},
expect.objectContaining({
message: `Shipping options with IDs ${shippingOption.id} do not have a price`,
}),
])
})
})

View File

@@ -666,10 +666,7 @@ medusaIntegrationTestRunner({
email: "tony@stark.com",
sales_channel_id: salesChannel.id,
})
console.log(
"updated.data.cart --- ",
JSON.stringify(updated.data.cart, null, 4)
)
expect(updated.status).toEqual(200)
expect(updated.data.cart).toEqual(
expect.objectContaining({

View File

@@ -0,0 +1,187 @@
import { ModuleRegistrationName, Modules } from "@medusajs/modules-sdk"
import { IPricingModuleService, IProductModuleService } from "@medusajs/types"
import {
ContainerRegistrationKeys,
remoteQueryObjectFromString,
} from "@medusajs/utils"
import { medusaIntegrationTestRunner } from "medusa-test-utils"
jest.setTimeout(50000)
const env = { MEDUSA_FF_MEDUSA_V2: true }
medusaIntegrationTestRunner({
env,
testSuite: ({ getContainer }) => {
describe("ProductVariant Price Sets", () => {
let appContainer
let productModule: IProductModuleService
let pricingModule: IPricingModuleService
let remoteQuery
let remoteLink
beforeAll(async () => {
appContainer = getContainer()
productModule = appContainer.resolve(ModuleRegistrationName.PRODUCT)
pricingModule = appContainer.resolve(ModuleRegistrationName.PRICING)
remoteQuery = appContainer.resolve(
ContainerRegistrationKeys.REMOTE_QUERY
)
remoteLink = appContainer.resolve(ContainerRegistrationKeys.REMOTE_LINK)
})
it("should query product variants and price set link with remote query", async () => {
const [product] = await productModule.create([
{
title: "Test product",
variants: [
{
title: "Test variant",
},
{
title: "Variant number 2",
},
],
},
])
await pricingModule.createRuleTypes([
{
name: "customer_group_id",
rule_attribute: "customer_group_id",
},
])
const [priceSet1, priceSet2] = await pricingModule.create([
{
rules: [{ rule_attribute: "customer_group_id" }],
prices: [
{
amount: 3000,
currency_code: "usd",
},
{
amount: 5000,
currency_code: "eur",
rules: {
customer_group_id: "vip",
},
},
],
},
{
rules: [{ rule_attribute: "customer_group_id" }],
prices: [
{
amount: 400,
currency_code: "eur",
},
{
amount: 500,
currency_code: "usd",
},
{
amount: 100,
currency_code: "usd",
rules: {
customer_group_id: "vip",
},
},
],
},
])
await remoteLink.create([
{
[Modules.PRODUCT]: {
variant_id: product.variants[0].id,
},
[Modules.PRICING]: {
price_set_id: priceSet1.id,
},
},
{
[Modules.PRODUCT]: {
variant_id: product.variants[1].id,
},
[Modules.PRICING]: {
price_set_id: priceSet2.id,
},
},
])
const query = remoteQueryObjectFromString({
entryPoint: "product",
variables: {
"variants.calculated_price": {
context: {
currency_code: "usd",
customer_group_id: "vip",
},
},
},
fields: [
"id",
"title",
"variants.title",
"variants.prices.amount",
"variants.prices.currency_code",
"variants.calculated_price.calculated_amount",
"variants.calculated_price.currency_code",
],
})
const link = await remoteQuery(query)
expect(link).toHaveLength(1)
expect(link).toEqual(
expect.arrayContaining([
expect.objectContaining({
title: "Test product",
variants: expect.arrayContaining([
expect.objectContaining({
title: "Test variant",
prices: expect.arrayContaining([
expect.objectContaining({
amount: 5000,
currency_code: "eur",
}),
expect.objectContaining({
amount: 3000,
currency_code: "usd",
}),
]),
calculated_price: {
calculated_amount: 3000,
currency_code: "usd",
},
}),
expect.objectContaining({
title: "Variant number 2",
prices: expect.arrayContaining([
expect.objectContaining({
amount: 400,
currency_code: "eur",
}),
expect.objectContaining({
amount: 500,
currency_code: "usd",
}),
expect.objectContaining({
amount: 100,
currency_code: "usd",
}),
]),
calculated_price: {
calculated_amount: 100,
currency_code: "usd",
},
}),
]),
}),
])
)
})
})
},
})

View File

@@ -3,9 +3,9 @@ import {
IFulfillmentModuleService,
IRegionModuleService,
} from "@medusajs/types"
import { ContainerRegistrationKeys } from "@medusajs/utils"
import { medusaIntegrationTestRunner } from "medusa-test-utils"
import { createAdminUser } from "../../../../helpers/create-admin-user"
import { ContainerRegistrationKeys } from "@medusajs/utils"
jest.setTimeout(50000)
@@ -183,7 +183,8 @@ medusaIntegrationTestRunner({
})
describe("GET /admin/shipping-options/:cart_id", () => {
it("should get all shipping options for a cart successfully", async () => {
// TODO: Enable it when product workflows manage inventory items
it.skip("should get all shipping options for a cart successfully", async () => {
const resp = await api.get(`/store/shipping-options/${cart.id}`)
const shippingOptions = resp.data.shipping_options

View File

@@ -5,6 +5,8 @@ interface StepInput {
entry_point: string
fields: string[]
variables?: Record<string, any>
throw_if_key_not_found?: boolean
throw_if_relation_not_found?: boolean | string[]
list?: boolean
}
@@ -21,7 +23,14 @@ export const useRemoteQueryStep = createStep(
variables,
})
const entities = await query(queryObject)
const config = {
throwIfKeyNotFound: !!data.throw_if_key_not_found,
throwIfRelationNotFound: data.throw_if_key_not_found
? data.throw_if_relation_not_found
: undefined,
}
const entities = await query(queryObject, undefined, config)
const result = list ? entities : entities[0]
return new StepResponse(result)

View File

@@ -1,79 +0,0 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { IPricingModuleService, PricingContext } from "@medusajs/types"
import {
ContainerRegistrationKeys,
MedusaError,
arrayDifference,
remoteQueryObjectFromString,
} from "@medusajs/utils"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
interface StepInput {
optionIds: string[]
context?: Record<string, unknown>
}
export const getShippingOptionPriceSetsStepId = "get-shipping-option-price-sets"
export const getShippingOptionPriceSetsStep = createStep(
getShippingOptionPriceSetsStepId,
async (data: StepInput, { container }) => {
if (!data.optionIds.length) {
return new StepResponse({})
}
const pricingModuleService = container.resolve<IPricingModuleService>(
ModuleRegistrationName.PRICING
)
const remoteQuery = container.resolve(
ContainerRegistrationKeys.REMOTE_QUERY
)
const query = remoteQueryObjectFromString({
entryPoint: "shipping_option_price_set",
fields: ["id", "shipping_option_id", "price_set_id"],
variables: {
shipping_option_id: data.optionIds,
},
})
const optionPriceSets = await remoteQuery(query)
const optionsMissingPrices = arrayDifference(
data.optionIds,
optionPriceSets.map((v) => v.shipping_option_id)
)
if (optionsMissingPrices.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Shipping options with IDs ${optionsMissingPrices.join(
", "
)} do not have a price`
)
}
const calculatedPriceSets = await pricingModuleService.calculatePrices(
{ id: optionPriceSets.map((v) => v.price_set_id) },
{ context: data.context as PricingContext["context"] }
)
const idToPriceSet = new Map<string, Record<string, any>>(
calculatedPriceSets.map((p) => [p.id, p])
)
const optionToCalculatedPriceSets = optionPriceSets.reduce(
(acc, { shipping_option_id, price_set_id }) => {
const calculatedPriceSet = idToPriceSet.get(price_set_id)
if (calculatedPriceSet) {
acc[shipping_option_id] = calculatedPriceSet
}
return acc
},
{}
)
return new StepResponse(optionToCalculatedPriceSets)
}
)

View File

@@ -9,7 +9,6 @@ export * from "./find-or-create-customer"
export * from "./find-sales-channel"
export * from "./get-actions-to-compute-from-promotions"
export * from "./get-item-tax-lines"
export * from "./get-shipping-option-price-sets"
export * from "./get-variant-price-sets"
export * from "./get-variants"
export * from "./prepare-adjustments-from-promotion-actions"

View File

@@ -1,4 +1,5 @@
import { Modules } from "@medusajs/modules-sdk"
import { ContainerRegistrationKeys } from "@medusajs/utils"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
type StepInput = {
@@ -13,7 +14,7 @@ export const linkCartAndPaymentCollectionsStepId =
export const linkCartAndPaymentCollectionsStep = createStep(
linkCartAndPaymentCollectionsStepId,
async (data: StepInput, { container }) => {
const remoteLink = container.resolve("remoteLink")
const remoteLink = container.resolve(ContainerRegistrationKeys.REMOTE_LINK)
const links = data.links.map((d) => ({
[Modules.CART]: { cart_id: d.cart_id },
@@ -29,7 +30,7 @@ export const linkCartAndPaymentCollectionsStep = createStep(
return
}
const remoteLink = container.resolve("remoteLink")
const remoteLink = container.resolve(ContainerRegistrationKeys.REMOTE_LINK)
const links = data.links.map((d) => ({
[Modules.CART]: { cart_id: d.cart_id },

View File

@@ -12,10 +12,7 @@ import { useRemoteQueryStep } from "../../../common/steps/use-remote-query"
import {
addToCartStep,
confirmInventoryStep,
getVariantPriceSetsStep,
getVariantsStep,
refreshCartShippingMethodsStep,
validateVariantsExistStep,
} from "../steps"
import { refreshCartPromotionsStep } from "../steps/refresh-cart-promotions"
import { updateTaxLinesStep } from "../steps/update-tax-lines"
@@ -35,69 +32,6 @@ export const addToCartWorkflow = createWorkflow(
return (data.input.items ?? []).map((i) => i.variant_id)
})
validateVariantsExistStep({ variantIds })
const variants = getVariantsStep({
filter: { id: variantIds },
config: {
select: [
"id",
"title",
"sku",
"barcode",
"product.id",
"product.title",
"product.description",
"product.subtitle",
"product.thumbnail",
"product.type",
"product.collection",
"product.handle",
],
relations: ["product"],
},
})
const salesChannelLocations = useRemoteQueryStep({
entry_point: "sales_channels",
fields: ["id", "name", "stock_locations.id", "stock_locations.name"],
variables: { id: input.cart.sales_channel_id },
})
const productVariantInventoryItems = useRemoteQueryStep({
entry_point: "product_variant_inventory_items",
fields: ["variant_id", "inventory_item_id", "required_quantity"],
variables: { variant_id: variantIds },
}).config({ name: "inventory-items" })
const confirmInventoryInput = transform(
{ productVariantInventoryItems, salesChannelLocations, input, variants },
(data) => {
if (!data.salesChannelLocations.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Sales channel ${data.input.cart.sales_channel_id} is not associated with any stock locations.`
)
}
const items = prepareConfirmInventoryInput({
product_variant_inventory_items: data.productVariantInventoryItems,
location_ids: data.salesChannelLocations[0].stock_locations.map(
(l) => l.id
),
items: data.input.items!,
variants: data.variants.map((v) => ({
id: v.id,
manage_inventory: v.manage_inventory,
})),
})
return { items }
}
)
confirmInventoryStep(confirmInventoryInput)
// TODO: This is on par with the context used in v1.*, but we can be more flexible.
const pricingContext = transform({ cart: input.cart }, (data) => {
return {
@@ -107,18 +41,115 @@ export const addToCartWorkflow = createWorkflow(
}
})
const priceSets = getVariantPriceSetsStep({
variantIds,
context: pricingContext,
const variants = useRemoteQueryStep({
entry_point: "variants",
fields: [
"id",
"title",
"sku",
"barcode",
"manage_inventory",
"product.id",
"product.title",
"product.description",
"product.subtitle",
"product.thumbnail",
"product.type",
"product.collection",
"product.handle",
"calculated_price.calculated_amount",
"inventory_items.inventory_item_id",
"inventory_items.required_quantity",
"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",
"inventory_items.inventory.location_levels.stock_locations.sales_channels.name",
],
variables: {
id: variantIds,
calculated_price: {
context: pricingContext,
},
},
throw_if_key_not_found: true,
})
const lineItems = transform({ priceSets, input, variants }, (data) => {
const confirmInventoryInput = transform({ input, variants }, (data) => {
const managedVariants = data.variants.filter((v) => v.manage_inventory)
if (!managedVariants.length) {
return { items: [] }
}
const productVariantInventoryItems: any[] = []
const stockLocations = data.variants
.map((v) => v.inventory_items)
.flat()
.map((ii) => {
productVariantInventoryItems.push({
variant_id: ii.variant_id,
inventory_item_id: ii.inventory_item_id,
required_quantity: ii.required_quantity,
})
return ii.inventory.location_levels
})
.flat()
.map((ll) => ll.stock_locations)
.flat()
const salesChannelId = data.input.cart.sales_channel_id
if (salesChannelId) {
const salesChannels = stockLocations
.map((sl) => sl.sales_channels)
.flat()
.filter((sc) => sc.id === salesChannelId)
if (!salesChannels.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Sales channel ${salesChannelId} is not associated with any stock locations.`
)
}
}
const priceNotFound: string[] = data.variants
.filter((v) => !v.calculated_price)
.map((v) => v.id)
if (priceNotFound.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Variants with IDs ${priceNotFound.join(", ")} do not have a price`
)
}
const items = prepareConfirmInventoryInput({
product_variant_inventory_items: productVariantInventoryItems,
location_ids: stockLocations.map((l) => l.id),
items: data.input.items!,
variants: data.variants.map((v) => ({
id: v.id,
manage_inventory: v.manage_inventory,
})),
})
return { items }
})
confirmInventoryStep(confirmInventoryInput)
const lineItems = transform({ input, variants }, (data) => {
const items = (data.input.items ?? []).map((item) => {
const variant = data.variants.find((v) => v.id === item.variant_id)!
return prepareLineItemData({
variant: variant,
unitPrice: data.priceSets[item.variant_id].calculated_amount,
unitPrice: variant.calculated_price.calculated_amount,
quantity: item.quantity,
metadata: item?.metadata ?? {},
cartId: data.input.cart.id,

View File

@@ -14,8 +14,6 @@ import {
findOrCreateCustomerStep,
findSalesChannelStep,
getVariantPriceSetsStep,
getVariantsStep,
validateVariantsExistStep,
} from "../steps"
import { refreshCartPromotionsStep } from "../steps/refresh-cart-promotions"
import { updateTaxLinesStep } from "../steps/update-tax-lines"
@@ -44,77 +42,9 @@ export const createCartWorkflow = createWorkflow(
findOrCreateCustomerStep({
customerId: input.customer_id,
email: input.email,
}),
validateVariantsExistStep({ variantIds })
})
)
const variants = getVariantsStep({
filter: { id: variantIds },
config: {
select: [
"id",
"title",
"sku",
"manage_inventory",
"barcode",
"product.id",
"product.title",
"product.description",
"product.subtitle",
"product.thumbnail",
"product.type",
"product.collection",
"product.handle",
],
relations: ["product"],
},
})
const salesChannelLocations = useRemoteQueryStep({
entry_point: "sales_channels",
fields: ["id", "name", "stock_locations.id", "stock_locations.name"],
variables: { id: salesChannel.id },
})
const productVariantInventoryItems = useRemoteQueryStep({
entry_point: "product_variant_inventory_items",
fields: ["variant_id", "inventory_item_id", "required_quantity"],
variables: { variant_id: variantIds },
}).config({ name: "inventory-items" })
const confirmInventoryInput = transform(
{ productVariantInventoryItems, salesChannelLocations, input, variants },
(data) => {
// We don't want to confirm inventory if there are no items in the cart.
if (!data.input.items) {
return { items: [] }
}
if (!data.salesChannelLocations.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Sales channel ${data.input.sales_channel_id} is not associated with any stock locations.`
)
}
const items = prepareConfirmInventoryInput({
product_variant_inventory_items: data.productVariantInventoryItems,
location_ids: data.salesChannelLocations[0].stock_locations.map(
(l) => l.id
),
items: data.input.items!,
variants: data.variants.map((v) => ({
id: v.id,
manage_inventory: v.manage_inventory,
})),
})
return { items }
}
)
confirmInventoryStep(confirmInventoryInput)
// TODO: This is on par with the context used in v1.*, but we can be more flexible.
const pricingContext = transform(
{ input, region, customerData },
@@ -127,6 +57,111 @@ export const createCartWorkflow = createWorkflow(
}
)
const variants = useRemoteQueryStep({
entry_point: "variants",
fields: [
"id",
"title",
"sku",
"manage_inventory",
"barcode",
"product.id",
"product.title",
"product.description",
"product.subtitle",
"product.thumbnail",
"product.type",
"product.collection",
"product.handle",
"calculated_price.calculated_amount",
"inventory_items.inventory_item_id",
"inventory_items.required_quantity",
"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",
"inventory_items.inventory.location_levels.stock_locations.sales_channels.name",
],
variables: {
id: variantIds,
calculated_price: {
context: pricingContext,
},
},
throw_if_key_not_found: true,
})
const confirmInventoryInput = transform(
{ input, salesChannel, variants },
(data) => {
const managedVariants = data.variants.filter((v) => v.manage_inventory)
if (!managedVariants.length) {
return { items: [] }
}
const productVariantInventoryItems: any[] = []
const stockLocations = managedVariants
.map((v) => v.inventory_items)
.flat()
.map((ii) => {
productVariantInventoryItems.push({
variant_id: ii.variant_id,
inventory_item_id: ii.inventory_item_id,
required_quantity: ii.required_quantity,
})
return ii.inventory.location_levels
})
.flat()
.map((ll) => ll.stock_locations)
.flat()
const salesChannelId = data.salesChannel?.id
if (salesChannelId) {
const salesChannels = stockLocations
.map((sl) => sl.sales_channels)
.flat()
.filter((sc) => sc.id === salesChannelId)
if (!salesChannels.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Sales channel ${salesChannelId} is not associated with any stock locations.`
)
}
}
const priceNotFound: string[] = data.variants
.filter((v) => !v.calculated_price)
.map((v) => v.id)
if (priceNotFound.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Variants with IDs ${priceNotFound.join(", ")} do not have a price`
)
}
const items = prepareConfirmInventoryInput({
product_variant_inventory_items: productVariantInventoryItems,
location_ids: stockLocations.map((l) => l.id),
items: data.input.items!,
variants: data.variants.map((v) => ({
id: v.id,
manage_inventory: v.manage_inventory,
})),
})
return { items }
}
)
confirmInventoryStep(confirmInventoryInput)
const priceSets = getVariantPriceSetsStep({
variantIds,
context: pricingContext,

View File

@@ -1,12 +1,11 @@
import { ListShippingOptionsForCartWorkflowInputDTO } from "@medusajs/types"
import { deepFlatMap, MedusaError } from "@medusajs/utils"
import {
createWorkflow,
transform,
WorkflowData,
} from "@medusajs/workflows-sdk"
import { useRemoteQueryStep } from "../../../common/steps/use-remote-query"
import { listShippingOptionsForContextStep } from "../../../shipping-options"
import { getShippingOptionPriceSetsStep } from "../steps"
export const listShippingOptionsForCartWorkflowId =
"list-shipping-options-for-cart"
@@ -15,72 +14,85 @@ export const listShippingOptionsForCartWorkflow = createWorkflow(
(input: WorkflowData<ListShippingOptionsForCartWorkflowInputDTO>) => {
const scLocationFulfillmentSets = useRemoteQueryStep({
entry_point: "sales_channels",
fields: ["stock_locations.fulfillment_sets.id"],
variables: { id: input.sales_channel_id },
})
fields: [
"stock_locations.fulfillment_sets.id",
"stock_locations.fulfillment_sets.name",
"stock_locations.fulfillment_sets.price_type",
"stock_locations.fulfillment_sets.service_zone_id",
"stock_locations.fulfillment_sets.shipping_profile_id",
"stock_locations.fulfillment_sets.provider_id",
"stock_locations.fulfillment_sets.data",
"stock_locations.fulfillment_sets.amount",
const listOptionsInput = transform(
{ scLocationFulfillmentSets, input },
(data) => {
const fulfillmentSetIds = data.scLocationFulfillmentSets
.map((sc) =>
sc.stock_locations.map((loc) =>
loc.fulfillment_sets.map(({ id }) => id)
)
)
.flat(2)
"stock_locations.fulfillment_sets.service_zones.shipping_options.id",
"stock_locations.fulfillment_sets.service_zones.shipping_options.name",
"stock_locations.fulfillment_sets.service_zones.shipping_options.price_type",
"stock_locations.fulfillment_sets.service_zones.shipping_options.service_zone_id",
"stock_locations.fulfillment_sets.service_zones.shipping_options.shipping_profile_id",
"stock_locations.fulfillment_sets.service_zones.shipping_options.provider_id",
"stock_locations.fulfillment_sets.service_zones.shipping_options.data",
"stock_locations.fulfillment_sets.service_zones.shipping_options.amount",
return {
"stock_locations.fulfillment_sets.service_zones.shipping_options.type.id",
"stock_locations.fulfillment_sets.service_zones.shipping_options.type.label",
"stock_locations.fulfillment_sets.service_zones.shipping_options.type.description",
"stock_locations.fulfillment_sets.service_zones.shipping_options.type.code",
"stock_locations.fulfillment_sets.service_zones.shipping_options.provider.id",
"stock_locations.fulfillment_sets.service_zones.shipping_options.provider.is_enabled",
"stock_locations.fulfillment_sets.service_zones.shipping_options.calculated_price.calculated_amount",
],
variables: {
id: input.sales_channel_id,
"stock_locations.fulfillment_sets.service_zones.shipping_options": {
context: {
fulfillment_set_id: fulfillmentSetIds,
address: {
city: data.input.shipping_address?.city,
country_code: data.input.shipping_address?.country_code,
province_code: data.input.shipping_address?.province,
city: input.shipping_address?.city,
country_code: input.shipping_address?.country_code,
province_code: input.shipping_address?.province,
},
},
config: {
select: [
"id",
"name",
"price_type",
"service_zone_id",
"shipping_profile_id",
"provider_id",
"data",
"amount",
],
relations: ["type", "provider"],
},
"stock_locations.fulfillment_sets.service_zones.shipping_options.calculated_price":
{
context: {
currency_code: input.currency_code,
},
},
}
}
)
const options = listShippingOptionsForContextStep(listOptionsInput)
const optionIds = transform({ options }, (data) =>
data.options.map((option) => option.id)
)
// TODO: Separate shipping options based on price_type, flat_rate vs calculated
const priceSets = getShippingOptionPriceSetsStep({
optionIds,
context: {
currency_code: input.currency_code,
},
})
const shippingOptionsWithPrice = transform(
{ priceSets, options },
{ options: scLocationFulfillmentSets },
(data) => {
const options = data.options.map((option) => {
const price = data.priceSets?.[option.id].calculated_amount
const optionsMissingPrices: string[] = []
return {
...option,
amount: price,
const options = deepFlatMap(
data.options,
"stock_locations.fulfillment_sets.service_zones.shipping_options.calculated_price",
({ shipping_options }) => {
const { calculated_price, ...options } = shipping_options ?? {}
if (!calculated_price) {
optionsMissingPrices.push(options.id)
}
return {
...options,
amount: calculated_price?.calculated_amount,
}
}
})
)
if (optionsMissingPrices.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Shipping options with IDs ${optionsMissingPrices.join(
", "
)} do not have a price`
)
}
return options
}

View File

@@ -3,6 +3,7 @@ import {
WorkflowData,
createStep,
createWorkflow,
transform,
} from "@medusajs/workflows-sdk"
import { useRemoteQueryStep } from "../../../common/steps/use-remote-query"
import {
@@ -49,21 +50,20 @@ export const refreshPaymentCollectionForCartWorkflow = createWorkflow(
"payment_collection.payment_sessions.id",
],
variables: { id: input.cart_id },
throw_if_key_not_found: true,
})
const cart = transform({ carts }, (data) => data.carts[0])
deletePaymentSessionStep({
payment_session_id: carts[0].payment_collection.payment_sessions?.[0].id,
payment_session_id: cart.payment_collection.payment_sessions?.[0].id,
})
// TODO: Temporary fixed cart total, so we can test the workflow.
// This will be removed when the totals utilities are built.
const cartTotal = 4242
updatePaymentCollectionStep({
selector: { id: carts[0].payment_collection.id },
selector: { id: cart.payment_collection.id },
update: {
amount: cartTotal,
currency_code: carts[0].currency_code,
amount: cart.total,
currency_code: cart.currency_code,
},
})
}

View File

@@ -7,12 +7,7 @@ import {
import { MedusaError } from "medusa-core-utils"
import { useRemoteQueryStep } from "../../../common/steps/use-remote-query"
import { updateLineItemsStep } from "../../line-item/steps"
import {
confirmInventoryStep,
getVariantPriceSetsStep,
getVariantsStep,
refreshCartShippingMethodsStep,
} from "../steps"
import { confirmInventoryStep, refreshCartShippingMethodsStep } from "../steps"
import { refreshCartPromotionsStep } from "../steps/refresh-cart-promotions"
import { cartFieldsForRefreshSteps } from "../utils/fields"
import { prepareConfirmInventoryInput } from "../utils/prepare-confirm-inventory-input"
@@ -25,9 +20,12 @@ export const updateLineItemInCartWorkflowId = "update-line-item-in-cart"
export const updateLineItemInCartWorkflow = createWorkflow(
updateLineItemInCartWorkflowId,
(input: WorkflowData<UpdateLineItemInCartWorkflowInputDTO>) => {
const item = transform({ input }, (data) => data.input.item)
const variantIds = transform({ input }, (data) => {
return [data.input.item.variant_id]
})
const pricingContext = transform({ cart: input.cart, item }, (data) => {
// TODO: This is on par with the context used in v1.*, but we can be more flexible.
const pricingContext = transform({ cart: input.cart }, (data) => {
return {
currency_code: data.cart.currency_code,
region_id: data.cart.region_id,
@@ -35,78 +33,124 @@ export const updateLineItemInCartWorkflow = createWorkflow(
}
})
const variantIds = transform({ input }, (data) => [
data.input.item.variant_id!,
])
const variants = useRemoteQueryStep({
entry_point: "variants",
fields: [
"id",
"title",
"sku",
"barcode",
"manage_inventory",
"product.id",
"product.title",
"product.description",
"product.subtitle",
"product.thumbnail",
"product.type",
"product.collection",
"product.handle",
const salesChannelLocations = useRemoteQueryStep({
entry_point: "sales_channels",
fields: ["id", "name", "stock_locations.id", "stock_locations.name"],
variables: { id: input.cart.sales_channel_id },
"calculated_price.calculated_amount",
"inventory_items.inventory_item_id",
"inventory_items.required_quantity",
"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",
"inventory_items.inventory.location_levels.stock_locations.sales_channels.name",
],
variables: {
id: variantIds,
calculated_price: {
context: pricingContext,
},
},
throw_if_key_not_found: true,
})
const productVariantInventoryItems = useRemoteQueryStep({
entry_point: "product_variant_inventory_items",
fields: ["variant_id", "inventory_item_id", "required_quantity"],
variables: { variant_id: variantIds },
}).config({ name: "inventory-items" })
const confirmInventoryInput = transform({ input, variants }, (data) => {
const managedVariants = data.variants.filter((v) => v.manage_inventory)
if (!managedVariants.length) {
return { items: [] }
}
const variants = getVariantsStep({
filter: { id: variantIds },
config: { select: ["id", "manage_inventory"] },
})
const productVariantInventoryItems: any[] = []
const confirmInventoryInput = transform(
{ productVariantInventoryItems, salesChannelLocations, input, variants },
(data) => {
if (!data.salesChannelLocations.length) {
const stockLocations = data.variants
.map((v) => v.inventory_items)
.flat()
.map((ii) => {
productVariantInventoryItems.push({
variant_id: ii.variant_id,
inventory_item_id: ii.inventory_item_id,
required_quantity: ii.required_quantity,
})
return ii.inventory.location_levels
})
.flat()
.map((ll) => ll.stock_locations)
.flat()
const salesChannelId = data.input.cart.sales_channel_id
if (salesChannelId) {
const salesChannels = stockLocations
.map((sl) => sl.sales_channels)
.flat()
.filter((sc) => sc.id === salesChannelId)
if (!salesChannels.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Sales channel ${data.input.cart.sales_channel_id} is not associated with any stock locations.`
`Sales channel ${salesChannelId} is not associated with any stock locations.`
)
}
const items = prepareConfirmInventoryInput({
product_variant_inventory_items: data.productVariantInventoryItems,
location_ids: data.salesChannelLocations[0].stock_locations.map(
(l) => l.id
),
items: [data.input.item],
variants: data.variants.map((v) => ({
id: v.id,
manage_inventory: v.manage_inventory,
})),
})
return { items }
}
)
const priceNotFound: string[] = data.variants
.filter((v) => !v.calculated_price)
.map((v) => v.id)
if (priceNotFound.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Variants with IDs ${priceNotFound.join(", ")} do not have a price`
)
}
const items = prepareConfirmInventoryInput({
product_variant_inventory_items: productVariantInventoryItems,
location_ids: stockLocations.map((l) => l.id),
items: [data.input.item!],
variants: data.variants.map((v) => ({
id: v.id,
manage_inventory: v.manage_inventory,
})),
})
return { items }
})
confirmInventoryStep(confirmInventoryInput)
const priceSets = getVariantPriceSetsStep({
variantIds,
context: pricingContext,
})
const lineItemUpdate = transform({ input, priceSets, item }, (data) => {
const price = data.priceSets[data.item.variant_id!].calculated_amount
const lineItemUpdate = transform({ input, variants }, (data) => {
const variant = data.variants[0]
const item = data.input.item
return {
data: {
...data.input.update,
unit_price: price,
unit_price: variant.calculated_price.calculated_amount,
},
selector: {
id: data.input.item.id,
id: item.id,
},
}
})
const result = updateLineItemsStep({
data: lineItemUpdate.data,
selector: lineItemUpdate.selector,
})
const result = updateLineItemsStep(lineItemUpdate)
const cart = useRemoteQueryStep({
entry_point: "cart",

View File

@@ -137,6 +137,51 @@ export default class FulfillmentModuleService<
return joinerConfig
}
private setupShippingOptionsConfig_(
filters,
config
):
| FulfillmentTypes.FilterableShippingOptionForContextProps["context"]
| undefined {
const fieldIdx = config.relations?.indexOf("shipping_options_context")
const shouldCalculatePrice = fieldIdx > -1
const shippingOptionsContext = filters.context ?? {}
delete filters.context
if (!shouldCalculatePrice) {
return
}
// cleanup virtual field "shipping_options_context"
config.relations?.splice(fieldIdx, 1)
return shippingOptionsContext
}
@InjectManager("baseRepository_")
// @ts-ignore
async listShippingOptions(
filters: FulfillmentTypes.FilterableShippingOptionForContextProps = {},
config: FindConfig<FulfillmentTypes.ShippingOptionDTO> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<FulfillmentTypes.ShippingOptionDTO[]> {
const optionsContext = this.setupShippingOptionsConfig_(filters, config)
if (optionsContext) {
filters.context = optionsContext
return await this.listShippingOptionsForContext(
filters,
config,
sharedContext
)
}
return await super.listShippingOptions(filters, config, sharedContext)
}
@InjectManager("baseRepository_")
async listShippingOptionsForContext(
filters: FulfillmentTypes.FilterableShippingOptionForContextProps,

View File

@@ -1,6 +1,6 @@
import { LINKS } from "@medusajs/utils"
import { ModuleJoinerConfig } from "@medusajs/types"
import { Modules } from "@medusajs/modules-sdk"
import { ModuleJoinerConfig } from "@medusajs/types"
import { LINKS } from "@medusajs/utils"
export const ProductVariantInventoryItem: ModuleJoinerConfig = {
serviceName: LINKS.ProductVariantInventoryItem,
@@ -48,6 +48,9 @@ export const ProductVariantInventoryItem: ModuleJoinerConfig = {
extends: [
{
serviceName: Modules.PRODUCT,
fieldAlias: {
inventory: "inventory_items.inventory",
},
relationship: {
serviceName: LINKS.ProductVariantInventoryItem,
primaryKey: "variant_id",

View File

@@ -43,6 +43,7 @@ export const ProductVariantPriceSet: ModuleJoinerConfig = {
serviceName: Modules.PRODUCT,
fieldAlias: {
price_set: "price_set_link.price_set",
prices: "price_set_link.price_set.prices",
calculated_price: {
path: "price_set_link.price_set.calculated_price",
forwardArgumentsOnPath: ["price_set_link.price_set"],

View File

@@ -159,15 +159,16 @@ export default class PricingModuleService<
): PricingContext["context"] | undefined {
const fieldIdx = config.relations?.indexOf("calculated_price")
const shouldCalculatePrice = fieldIdx > -1
const pricingContext = filters.context ?? {}
delete filters.context
if (!shouldCalculatePrice) {
return
}
let pricingContext = filters.context ?? {}
// cleanup virtual field "calculated_price"
config.relations?.splice(fieldIdx, 1)
delete filters.context
return pricingContext
}
@@ -746,9 +747,9 @@ export default class PricingModuleService<
) {
const input = Array.isArray(data) ? data : [data]
const ruleAttributes = data
.map((d) => d.rules?.map((r) => r.rule_attribute) ?? [])
.flat()
const ruleAttributes = deduplicate(
data.map((d) => d.rules?.map((r) => r.rule_attribute) ?? []).flat()
)
const ruleTypes = await this.ruleTypeService_.list(
{ rule_attribute: ruleAttributes },

View File

@@ -1090,7 +1090,7 @@ export interface IFulfillmentModuleService extends IModuleService {
* ```
*/
listShippingOptions(
filters?: FilterableShippingOptionProps,
filters?: FilterableShippingOptionForContextProps,
config?: FindConfig<ShippingOptionDTO>,
sharedContext?: Context
): Promise<ShippingOptionDTO[]>
@@ -1156,6 +1156,7 @@ export interface IFulfillmentModuleService extends IModuleService {
* )
* ```
*/
listShippingOptionsForContext(
filters: FilterableShippingOptionForContextProps,
config?: FindConfig<ShippingOptionDTO>,

View File

@@ -0,0 +1,203 @@
import { deepFlatMap } from "../deep-flat-map"
describe("deepFlatMap", function () {
it("should return flat map of nested objects", function () {
const data = [
{
id: "sales_channel_1",
stock_locations: [
{
id: "location_1",
fulfillment_sets: [
{
id: "fset_1",
name: "Test 123",
service_zones: [
{
id: "zone_123",
shipping_options: [
{
id: "so_zone_123 1111",
calculated_price: {
calculated_amount: 3000,
},
},
{
id: "so_zone_123 22222",
calculated_price: {
calculated_amount: 6000,
},
},
],
},
{
id: "zone_567",
shipping_options: [
{
id: "zone 567 11111",
calculated_price: {
calculated_amount: 1230,
},
},
{
id: "zone 567 22222",
calculated_price: {
calculated_amount: 1230,
},
},
],
},
],
},
],
},
{
id: "location_2",
fulfillment_sets: [
{
id: "fset_2",
name: "fset name 2",
service_zones: [
{
id: "zone_ABC",
shipping_options: [
{
id: "zone_abc_unique",
calculated_price: {
calculated_amount: 70,
},
},
],
},
],
},
],
},
],
},
{
id: "sales_channel_2",
stock_locations: [
{
id: "location_5",
fulfillment_sets: [
{
id: "fset_aaa",
name: "Test aaa",
service_zones: [
{
id: "zone_aaa",
shipping_options: [
{
id: "so_zone_aaa aaaa",
calculated_price: {
calculated_amount: 500,
},
},
{
id: "so_zone_aaa bbbb",
calculated_price: {
calculated_amount: 12,
},
},
],
},
],
},
],
},
],
},
]
const result = deepFlatMap(
data,
"stock_locations.fulfillment_sets.service_zones.shipping_options.calculated_price",
({
root_,
stock_locations,
fulfillment_sets,
service_zones,
shipping_options,
calculated_price,
}) => {
return {
sales_channel_id: root_.id,
stock_location_id: stock_locations.id,
fulfillment_set_id: fulfillment_sets.id,
fulfillment_set_name: fulfillment_sets.name,
service_zone_id: service_zones.id,
shipping_option_id: shipping_options.id,
price: calculated_price.calculated_amount,
}
}
)
expect(result).toEqual([
{
sales_channel_id: "sales_channel_1",
stock_location_id: "location_1",
fulfillment_set_id: "fset_1",
fulfillment_set_name: "Test 123",
service_zone_id: "zone_123",
shipping_option_id: "so_zone_123 1111",
price: 3000,
},
{
sales_channel_id: "sales_channel_1",
stock_location_id: "location_1",
fulfillment_set_id: "fset_1",
fulfillment_set_name: "Test 123",
service_zone_id: "zone_123",
shipping_option_id: "so_zone_123 22222",
price: 6000,
},
{
sales_channel_id: "sales_channel_1",
stock_location_id: "location_1",
fulfillment_set_id: "fset_1",
fulfillment_set_name: "Test 123",
service_zone_id: "zone_567",
shipping_option_id: "zone 567 11111",
price: 1230,
},
{
sales_channel_id: "sales_channel_1",
stock_location_id: "location_1",
fulfillment_set_id: "fset_1",
fulfillment_set_name: "Test 123",
service_zone_id: "zone_567",
shipping_option_id: "zone 567 22222",
price: 1230,
},
{
sales_channel_id: "sales_channel_1",
stock_location_id: "location_2",
fulfillment_set_id: "fset_2",
fulfillment_set_name: "fset name 2",
service_zone_id: "zone_ABC",
shipping_option_id: "zone_abc_unique",
price: 70,
},
{
sales_channel_id: "sales_channel_2",
stock_location_id: "location_5",
fulfillment_set_id: "fset_aaa",
fulfillment_set_name: "Test aaa",
service_zone_id: "zone_aaa",
shipping_option_id: "so_zone_aaa aaaa",
price: 500,
},
{
sales_channel_id: "sales_channel_2",
stock_location_id: "location_5",
fulfillment_set_id: "fset_aaa",
fulfillment_set_name: "Test aaa",
service_zone_id: "zone_aaa",
shipping_option_id: "so_zone_aaa bbbb",
price: 12,
},
])
})
})

View File

@@ -0,0 +1,103 @@
import { isDefined } from "./is-defined"
import { isObject } from "./is-object"
/**
* @description
* This function is used to flatten nested objects and arrays
*
* @example
*
* ```ts
* const data = {
* root_level_property: "root level",
* products: [
* {
* id: "1",
* name: "product 1",
* variants: [
* { id: "1.1", name: "variant 1.1" },
* { id: "1.2", name: "variant 1.2" },
* ],
* },
* {
* id: "2",
* name: "product 2",
* variants: [
* { id: "2.1", name: "variant 2.1" },
* { id: "2.2", name: "variant 2.2" },
* ],
* },
* ],
* }
*
* const flat = deepFlatMap(
* data,
* "products.variants",
* ({ root_, products, variants }) => {
* return {
* root_level_property: root_.root_level_property,
* product_id: products.id,
* product_name: products.name,
* variant_id: variants.id,
* variant_name: variants.name,
* }
* }
* )
* ```
*/
export function deepFlatMap(
data: any,
path: string,
callback: (context: Record<string, any>) => any
) {
const ROOT_LEVEL = "root_"
const keys = path.split(".")
keys.unshift(ROOT_LEVEL)
const lastKey = keys[keys.length - 1]
const stack: {
element: any
path: string[]
context: Record<string, any>
}[] = [{ element: { [ROOT_LEVEL]: data }, path: keys, context: {} }]
const results: any[] = []
while (stack.length > 0) {
const { element, path, context } = stack.shift()!
const currentKey = path[0]
const remainingPath = path.slice(1)
if (!isDefined(element[currentKey])) {
callback({ ...context })
continue
}
if (remainingPath.length === 0) {
if (Array.isArray(element[currentKey])) {
element[currentKey].forEach((item) => {
results.push(callback({ ...context, [lastKey]: item }))
})
} else if (isObject(element[currentKey])) {
results.push(callback({ ...context, [lastKey]: element[currentKey] }))
}
} else {
if (Array.isArray(element[currentKey])) {
element[currentKey].forEach((item) => {
stack.push({
element: item,
path: remainingPath,
context: { ...context, [currentKey]: item },
})
})
} else if (isObject(element[currentKey])) {
stack.push({
element: element[currentKey],
path: remainingPath,
context: { ...context, [currentKey]: element[currentKey] },
})
}
}
}
return results
}

View File

@@ -10,6 +10,7 @@ export * from "./create-psql-index-helper"
export * from "./deduplicate"
export * from "./deep-copy"
export * from "./deep-equal-obj"
export * from "./deep-flat-map"
export * from "./errors"
export * from "./generate-entity-id"
export * from "./generate-linkable-keys-map"