feat:Allow updating prices through product update workflow (#8316)
* feat:Allow updating prices through product update workflow * fix:Changes based on PR feedback
This commit is contained in:
@@ -217,7 +217,20 @@ medusaIntegrationTestRunner({
|
||||
title: "Test variant 2",
|
||||
allow_backorder: false,
|
||||
manage_inventory: true,
|
||||
// TODO: Since we are doing a product update, there won't be any prices created for the variant
|
||||
prices: [
|
||||
expect.objectContaining({
|
||||
currency_code: "usd",
|
||||
amount: 200,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
currency_code: "eur",
|
||||
amount: 65,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
currency_code: "dkk",
|
||||
amount: 50,
|
||||
}),
|
||||
],
|
||||
options: [
|
||||
expect.objectContaining({
|
||||
value: "small",
|
||||
|
||||
@@ -1390,7 +1390,20 @@ medusaIntegrationTestRunner({
|
||||
barcode: "test-barcode",
|
||||
ean: "test-ean",
|
||||
upc: "test-upc",
|
||||
// BREAKING: Price updates are no longer supported through the product update endpoint. There is a batch variants endpoint for this purpose
|
||||
prices: [
|
||||
{
|
||||
currency_code: "usd",
|
||||
amount: 200,
|
||||
},
|
||||
{
|
||||
currency_code: "eur",
|
||||
amount: 65,
|
||||
},
|
||||
{
|
||||
currency_code: "dkk",
|
||||
amount: 50,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
tags: [{ value: "123" }],
|
||||
@@ -1478,10 +1491,20 @@ medusaIntegrationTestRunner({
|
||||
origin_country: null,
|
||||
prices: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
amount: 100,
|
||||
amount: 200,
|
||||
created_at: expect.any(String),
|
||||
currency_code: "usd",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
amount: 65,
|
||||
created_at: expect.any(String),
|
||||
currency_code: "eur",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
amount: 50,
|
||||
created_at: expect.any(String),
|
||||
currency_code: "dkk",
|
||||
}),
|
||||
]),
|
||||
product_id: baseProduct.id,
|
||||
title: "New variant",
|
||||
@@ -1492,6 +1515,88 @@ medusaIntegrationTestRunner({
|
||||
)
|
||||
})
|
||||
|
||||
it("updates product variants (update price on existing variant, create new variant)", async () => {
|
||||
const payload = {
|
||||
variants: [
|
||||
{
|
||||
id: baseProduct.variants[0].id,
|
||||
prices: [
|
||||
{
|
||||
currency_code: "usd",
|
||||
amount: 200,
|
||||
},
|
||||
{
|
||||
currency_code: "dkk",
|
||||
amount: 50,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "New variant",
|
||||
prices: [
|
||||
{
|
||||
currency_code: "usd",
|
||||
amount: 150,
|
||||
},
|
||||
{
|
||||
currency_code: "dkk",
|
||||
amount: 20,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const response = await api
|
||||
.post(`/admin/products/${baseProduct.id}`, payload, adminHeaders)
|
||||
.catch((err) => {
|
||||
console.log(err)
|
||||
})
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
|
||||
expect(response.data.product).toEqual(
|
||||
expect.objectContaining({
|
||||
id: baseProduct.id,
|
||||
variants: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: baseProduct.variants[0].id,
|
||||
prices: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
amount: 200,
|
||||
created_at: expect.any(String),
|
||||
currency_code: "usd",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
amount: 50,
|
||||
created_at: expect.any(String),
|
||||
currency_code: "dkk",
|
||||
}),
|
||||
]),
|
||||
product_id: baseProduct.id,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
title: "New variant",
|
||||
prices: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
amount: 150,
|
||||
created_at: expect.any(String),
|
||||
currency_code: "usd",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
amount: 20,
|
||||
created_at: expect.any(String),
|
||||
currency_code: "dkk",
|
||||
}),
|
||||
]),
|
||||
product_id: baseProduct.id,
|
||||
}),
|
||||
]),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("updates product (removes images when empty array included)", async () => {
|
||||
const payload = {
|
||||
images: [],
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
|
||||
import { UpdateProductsStepInput } from "./update-products"
|
||||
import { IProductModuleService } from "@medusajs/types"
|
||||
import { ModuleRegistrationName } from "@medusajs/utils"
|
||||
|
||||
export const getVariantIdsForProductsStepId = "get-variant-ids-for-products"
|
||||
export const getVariantIdsForProductsStep = createStep(
|
||||
getVariantIdsForProductsStepId,
|
||||
async (data: UpdateProductsStepInput, { container }) => {
|
||||
const service = container.resolve<IProductModuleService>(
|
||||
ModuleRegistrationName.PRODUCT
|
||||
)
|
||||
let filters = {}
|
||||
if ("products" in data) {
|
||||
if (!data.products.length) {
|
||||
return new StepResponse([])
|
||||
}
|
||||
|
||||
filters = {
|
||||
id: data.products.map((p) => p.id),
|
||||
}
|
||||
} else {
|
||||
filters = data.selector
|
||||
}
|
||||
|
||||
const products = await service.listProducts(filters, {
|
||||
select: ["variants.id"],
|
||||
relations: ["variants"],
|
||||
take: null,
|
||||
})
|
||||
|
||||
return new StepResponse(
|
||||
products.flatMap((p) => p.variants.map((v) => v.id))
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -38,11 +38,6 @@ export const groupProductsForBatchStep = createStep(
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: Currently the update product workflow doesn't update variant pricing, but we should probably add support for it.
|
||||
product.variants?.forEach((variant: any) => {
|
||||
delete variant.prices
|
||||
})
|
||||
|
||||
acc.toUpdate.push(
|
||||
product as HttpTypes.AdminUpdateProduct & { id: string }
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ export const waitConfirmationProductImportStep = createStep(
|
||||
name: waitConfirmationProductImportStepId,
|
||||
async: true,
|
||||
// After an hour we want to timeout and cancel the import so we don't have orphaned workflows
|
||||
timeout: 60 * 60 * 1 * 1000,
|
||||
timeout: 60 * 60 * 1,
|
||||
},
|
||||
async () => {}
|
||||
)
|
||||
|
||||
@@ -22,3 +22,4 @@ export * from "./update-product-variants"
|
||||
export * from "./update-products"
|
||||
export * from "./export-products"
|
||||
export * from "./import-products"
|
||||
export * from "./upsert-variant-prices"
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { updateProductsStep } from "../steps/update-products"
|
||||
|
||||
import { ProductTypes } from "@medusajs/types"
|
||||
import {
|
||||
CreateMoneyAmountDTO,
|
||||
ProductTypes,
|
||||
UpdateProductVariantWorkflowInputDTO,
|
||||
} from "@medusajs/types"
|
||||
import { arrayDifference, Modules } from "@medusajs/utils"
|
||||
import {
|
||||
createWorkflow,
|
||||
@@ -12,17 +16,21 @@ import {
|
||||
dismissRemoteLinkStep,
|
||||
useRemoteQueryStep,
|
||||
} from "../../common"
|
||||
import { upsertVariantPricesWorkflow } from "./upsert-variant-prices"
|
||||
import { getVariantIdsForProductsStep } from "../steps/get-variant-ids-for-products"
|
||||
|
||||
type UpdateProductsStepInputSelector = {
|
||||
selector: ProductTypes.FilterableProductProps
|
||||
update: ProductTypes.UpdateProductDTO & {
|
||||
update: Omit<ProductTypes.UpdateProductDTO, "variants"> & {
|
||||
sales_channels?: { id: string }[]
|
||||
variants?: UpdateProductVariantWorkflowInputDTO[]
|
||||
}
|
||||
}
|
||||
|
||||
type UpdateProductsStepInputProducts = {
|
||||
products: (ProductTypes.UpsertProductDTO & {
|
||||
products: (Omit<ProductTypes.UpsertProductDTO, "variants"> & {
|
||||
sales_channels?: { id: string }[]
|
||||
variants?: UpdateProductVariantWorkflowInputDTO[]
|
||||
})[]
|
||||
}
|
||||
|
||||
@@ -46,6 +54,10 @@ function prepareUpdateProductInput({
|
||||
products: input.products.map((p) => ({
|
||||
...p,
|
||||
sales_channels: undefined,
|
||||
variants: p.variants?.map((v) => ({
|
||||
...v,
|
||||
prices: undefined,
|
||||
})),
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -55,6 +67,10 @@ function prepareUpdateProductInput({
|
||||
update: {
|
||||
...input.update,
|
||||
sales_channels: undefined,
|
||||
variants: input.update?.variants?.map((v) => ({
|
||||
...v,
|
||||
prices: undefined,
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -120,19 +136,67 @@ function prepareSalesChannelLinks({
|
||||
return []
|
||||
}
|
||||
|
||||
function prepareToDeleteLinks({
|
||||
currentLinks,
|
||||
function prepareVariantPrices({
|
||||
input,
|
||||
updatedProducts,
|
||||
}: {
|
||||
currentLinks: {
|
||||
updatedProducts: ProductTypes.ProductDTO[]
|
||||
input: WorkflowInput
|
||||
}): {
|
||||
variant_id: string
|
||||
product_id: string
|
||||
prices?: CreateMoneyAmountDTO[]
|
||||
}[] {
|
||||
if ("products" in input) {
|
||||
if (!input.products.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Note: We rely on the ordering of input and update here.
|
||||
return input.products.flatMap((product, i) => {
|
||||
if (!product.variants?.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
const updatedProduct = updatedProducts[i]
|
||||
return product.variants.map((variant, j) => {
|
||||
const updatedVariant = updatedProduct.variants[j]
|
||||
|
||||
return {
|
||||
product_id: updatedProduct.id,
|
||||
variant_id: updatedVariant.id,
|
||||
prices: variant.prices,
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (input.selector && input.update?.variants?.length) {
|
||||
return updatedProducts.flatMap((p) => {
|
||||
return input.update.variants!.map((variant, i) => ({
|
||||
product_id: p.id,
|
||||
variant_id: p.variants[i].id,
|
||||
prices: variant.prices,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function prepareToDeleteSalesChannelLinks({
|
||||
currentSalesChannelLinks,
|
||||
}: {
|
||||
currentSalesChannelLinks: {
|
||||
product_id: string
|
||||
sales_channel_id: string
|
||||
}[]
|
||||
}) {
|
||||
if (!currentLinks.length) {
|
||||
if (!currentSalesChannelLinks.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
return currentLinks.map(({ product_id, sales_channel_id }) => ({
|
||||
return currentSalesChannelLinks.map(({ product_id, sales_channel_id }) => ({
|
||||
[Modules.PRODUCT]: {
|
||||
product_id,
|
||||
},
|
||||
@@ -148,7 +212,7 @@ export const updateProductsWorkflow = createWorkflow(
|
||||
(
|
||||
input: WorkflowData<WorkflowInput>
|
||||
): WorkflowData<ProductTypes.ProductDTO[]> => {
|
||||
// TODO: Delete price sets for removed variants
|
||||
const previousVariantIds = getVariantIdsForProductsStep(input)
|
||||
|
||||
const toUpdateInput = transform({ input }, prepareUpdateProductInput)
|
||||
const updatedProducts = updateProductsStep(toUpdateInput)
|
||||
@@ -157,20 +221,32 @@ export const updateProductsWorkflow = createWorkflow(
|
||||
updateProductIds
|
||||
)
|
||||
|
||||
const currentLinks = useRemoteQueryStep({
|
||||
entry_point: "product_sales_channel",
|
||||
fields: ["product_id", "sales_channel_id"],
|
||||
variables: { filters: { product_id: updatedProductIds } },
|
||||
})
|
||||
|
||||
const toDeleteLinks = transform({ currentLinks }, prepareToDeleteLinks)
|
||||
|
||||
const salesChannelLinks = transform(
|
||||
{ input, updatedProducts },
|
||||
prepareSalesChannelLinks
|
||||
)
|
||||
|
||||
dismissRemoteLinkStep(toDeleteLinks)
|
||||
const variantPrices = transform(
|
||||
{ input, updatedProducts },
|
||||
prepareVariantPrices
|
||||
)
|
||||
|
||||
const currentSalesChannelLinks = useRemoteQueryStep({
|
||||
entry_point: "product_sales_channel",
|
||||
fields: ["product_id", "sales_channel_id"],
|
||||
variables: { filters: { product_id: updatedProductIds } },
|
||||
})
|
||||
|
||||
const toDeleteSalesChannelLinks = transform(
|
||||
{ currentSalesChannelLinks },
|
||||
prepareToDeleteSalesChannelLinks
|
||||
)
|
||||
|
||||
upsertVariantPricesWorkflow.runAsStep({
|
||||
input: { variantPrices, previousVariantIds },
|
||||
})
|
||||
|
||||
dismissRemoteLinkStep(toDeleteSalesChannelLinks)
|
||||
|
||||
createRemoteLinkStep(salesChannelLinks)
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
CreatePricesDTO,
|
||||
UpdatePricesDTO,
|
||||
CreatePriceSetDTO,
|
||||
} from "@medusajs/types"
|
||||
import { Modules, arrayDifference } from "@medusajs/utils"
|
||||
import {
|
||||
WorkflowData,
|
||||
createWorkflow,
|
||||
transform,
|
||||
} from "@medusajs/workflows-sdk"
|
||||
import { removeRemoteLinkStep, useRemoteQueryStep } from "../../common"
|
||||
import { createPriceSetsStep, updatePriceSetsStep } from "../../pricing"
|
||||
import { createVariantPricingLinkStep } from "../steps"
|
||||
|
||||
type WorkflowInput = {
|
||||
variantPrices: {
|
||||
variant_id: string
|
||||
product_id: string
|
||||
prices?: (CreatePricesDTO | UpdatePricesDTO)[]
|
||||
}[]
|
||||
previousVariantIds: string[]
|
||||
}
|
||||
|
||||
export const upsertVariantPricesWorkflowId = "upsert-variant-prices"
|
||||
export const upsertVariantPricesWorkflow = createWorkflow(
|
||||
upsertVariantPricesWorkflowId,
|
||||
(input: WorkflowData<WorkflowInput>): WorkflowData<void> => {
|
||||
const removedVariantIds = transform({ input }, (data) => {
|
||||
return arrayDifference(
|
||||
data.input.previousVariantIds,
|
||||
data.input.variantPrices.map((v) => v.variant_id)
|
||||
)
|
||||
})
|
||||
|
||||
removeRemoteLinkStep({
|
||||
[Modules.PRODUCT]: { variant_id: removedVariantIds },
|
||||
}).config({ name: "remove-variant-link-step" })
|
||||
|
||||
const { newVariants, existingVariants } = transform({ input }, (data) => {
|
||||
const previousMap = new Set(data.input.previousVariantIds.map((v) => v))
|
||||
|
||||
return {
|
||||
existingVariants: data.input.variantPrices.filter((v) =>
|
||||
previousMap.has(v.variant_id)
|
||||
),
|
||||
newVariants: data.input.variantPrices.filter(
|
||||
(v) => !previousMap.has(v.variant_id)
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
const existingVariantIds = transform({ existingVariants }, (data) =>
|
||||
data.existingVariants.map((v) => v.variant_id)
|
||||
)
|
||||
|
||||
const existingLinks = useRemoteQueryStep({
|
||||
entry_point: "product_variant_price_set",
|
||||
fields: ["variant_id", "price_set_id"],
|
||||
variables: { filters: { variant_id: existingVariantIds } },
|
||||
})
|
||||
|
||||
const pricesToUpdate = transform(
|
||||
{ existingVariants, existingLinks },
|
||||
(data) => {
|
||||
const linksMap = new Map(
|
||||
data.existingLinks.map((l) => [l.variant_id, l.price_set_id])
|
||||
)
|
||||
|
||||
return {
|
||||
price_sets: data.existingVariants
|
||||
.map((v) => {
|
||||
const priceSetId = linksMap.get(v.variant_id)
|
||||
|
||||
if (!priceSetId) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
id: priceSetId,
|
||||
prices: v.prices,
|
||||
}
|
||||
})
|
||||
.filter(Boolean),
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
updatePriceSetsStep(pricesToUpdate)
|
||||
|
||||
// Note: We rely on the same order of input and output when creating variants here, make sure that assumption holds
|
||||
const pricesToCreate = transform({ newVariants }, (data) =>
|
||||
data.newVariants.map((v) => {
|
||||
return {
|
||||
prices: v.prices,
|
||||
} as CreatePriceSetDTO
|
||||
})
|
||||
)
|
||||
|
||||
const createdPriceSets = createPriceSetsStep(pricesToCreate)
|
||||
|
||||
const variantAndPriceSetLinks = transform(
|
||||
{ newVariants, createdPriceSets },
|
||||
(data) => {
|
||||
return {
|
||||
links: data.newVariants.map((variant, i) => ({
|
||||
variant_id: variant.variant_id,
|
||||
price_set_id: data.createdPriceSets[i].id,
|
||||
})),
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
createVariantPricingLinkStep(variantAndPriceSetLinks)
|
||||
}
|
||||
)
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
CreateMoneyAmountDTO,
|
||||
FilterableMoneyAmountProps,
|
||||
MoneyAmountDTO,
|
||||
UpdateMoneyAmountDTO,
|
||||
} from "./money-amount"
|
||||
|
||||
export interface PricingRepositoryService {
|
||||
@@ -227,6 +228,18 @@ export interface CreatePricesDTO extends CreateMoneyAmountDTO {
|
||||
rules?: CreatePriceSetPriceRules
|
||||
}
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*
|
||||
* The prices to create part of a price set.
|
||||
*/
|
||||
export interface UpdatePricesDTO extends UpdateMoneyAmountDTO {
|
||||
/**
|
||||
* The rules to add to the price. The object's keys are the attribute, and values are the value of that rule associated with this price.
|
||||
*/
|
||||
rules?: CreatePriceSetPriceRules
|
||||
}
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user