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:
Stevche Radevski
2024-07-29 19:56:03 +03:00
committed by GitHub
parent ed67d44d28
commit e98012a858
9 changed files with 382 additions and 27 deletions

View File

@@ -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",

View File

@@ -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: [],

View File

@@ -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))
)
}
)

View File

@@ -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 }
)

View File

@@ -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 () => {}
)

View File

@@ -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"

View File

@@ -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)

View File

@@ -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)
}
)

View File

@@ -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
*