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 authored and GitHub committed 2024-07-29 19:56:03 +03:00
1 parent ed67d44d28
commit e98012a858
9 files changed
+382 -27

No files matched your search

@@ -217,7 +217,20 @@ medusaIntegrationTestRunner({
title: "Test variant 2", title: "Test variant 2",
allow_backorder: false, allow_backorder: false,
manage_inventory: true, 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: [ options: [
expect.objectContaining({ expect.objectContaining({
value: "small", value: "small",
@@ -1390,7 +1390,20 @@ medusaIntegrationTestRunner({
barcode: "test-barcode", barcode: "test-barcode",
ean: "test-ean", ean: "test-ean",
upc: "test-upc", 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" }], tags: [{ value: "123" }],
@@ -1478,10 +1491,20 @@ medusaIntegrationTestRunner({
origin_country: null, origin_country: null,
prices: expect.arrayContaining([ prices: expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
amount: 100, amount: 200,
created_at: expect.any(String), created_at: expect.any(String),
currency_code: "usd", 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, product_id: baseProduct.id,
title: "New variant", 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 () => { it("updates product (removes images when empty array included)", async () => {
const payload = { const payload = {
images: [], 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( acc.toUpdate.push(
product as HttpTypes.AdminUpdateProduct & { id: string } product as HttpTypes.AdminUpdateProduct & { id: string }
) )
@@ -7,7 +7,7 @@ export const waitConfirmationProductImportStep = createStep(
name: waitConfirmationProductImportStepId, name: waitConfirmationProductImportStepId,
async: true, async: true,
// After an hour we want to timeout and cancel the import so we don't have orphaned workflows // 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 () => {} async () => {}
) )
@@ -22,3 +22,4 @@ export * from "./update-product-variants"
export * from "./update-products" export * from "./update-products"
export * from "./export-products" export * from "./export-products"
export * from "./import-products" export * from "./import-products"
export * from "./upsert-variant-prices"
@@ -1,6 +1,10 @@
import { updateProductsStep } from "../steps/update-products" 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 { arrayDifference, Modules } from "@medusajs/utils"
import { import {
createWorkflow, createWorkflow,
@@ -12,17 +16,21 @@ import {
dismissRemoteLinkStep, dismissRemoteLinkStep,
useRemoteQueryStep, useRemoteQueryStep,
} from "../../common" } from "../../common"
import { upsertVariantPricesWorkflow } from "./upsert-variant-prices"
import { getVariantIdsForProductsStep } from "../steps/get-variant-ids-for-products"
type UpdateProductsStepInputSelector = { type UpdateProductsStepInputSelector = {
selector: ProductTypes.FilterableProductProps selector: ProductTypes.FilterableProductProps
update: ProductTypes.UpdateProductDTO & { update: Omit<ProductTypes.UpdateProductDTO, "variants"> & {
sales_channels?: { id: string }[] sales_channels?: { id: string }[]
variants?: UpdateProductVariantWorkflowInputDTO[]
} }
} }
type UpdateProductsStepInputProducts = { type UpdateProductsStepInputProducts = {
products: (ProductTypes.UpsertProductDTO & { products: (Omit<ProductTypes.UpsertProductDTO, "variants"> & {
sales_channels?: { id: string }[] sales_channels?: { id: string }[]
variants?: UpdateProductVariantWorkflowInputDTO[]
})[] })[]
} }
@@ -46,6 +54,10 @@ function prepareUpdateProductInput({
products: input.products.map((p) => ({ products: input.products.map((p) => ({
...p, ...p,
sales_channels: undefined, sales_channels: undefined,
variants: p.variants?.map((v) => ({
...v,
prices: undefined,
})),
})), })),
} }
} }
@@ -55,6 +67,10 @@ function prepareUpdateProductInput({
update: { update: {
...input.update, ...input.update,
sales_channels: undefined, sales_channels: undefined,
variants: input.update?.variants?.map((v) => ({
...v,
prices: undefined,
})),
}, },
} }
} }
@@ -120,19 +136,67 @@ function prepareSalesChannelLinks({
return [] return []
} }
function prepareToDeleteLinks({ function prepareVariantPrices({
currentLinks, 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 product_id: string
sales_channel_id: string sales_channel_id: string
}[] }[]
}) { }) {
if (!currentLinks.length) { if (!currentSalesChannelLinks.length) {
return [] return []
} }
return currentLinks.map(({ product_id, sales_channel_id }) => ({ return currentSalesChannelLinks.map(({ product_id, sales_channel_id }) => ({
[Modules.PRODUCT]: { [Modules.PRODUCT]: {
product_id, product_id,
}, },
@@ -148,7 +212,7 @@ export const updateProductsWorkflow = createWorkflow(
( (
input: WorkflowData<WorkflowInput> input: WorkflowData<WorkflowInput>
): WorkflowData<ProductTypes.ProductDTO[]> => { ): WorkflowData<ProductTypes.ProductDTO[]> => {
// TODO: Delete price sets for removed variants const previousVariantIds = getVariantIdsForProductsStep(input)
const toUpdateInput = transform({ input }, prepareUpdateProductInput) const toUpdateInput = transform({ input }, prepareUpdateProductInput)
const updatedProducts = updateProductsStep(toUpdateInput) const updatedProducts = updateProductsStep(toUpdateInput)
@@ -157,20 +221,32 @@ export const updateProductsWorkflow = createWorkflow(
updateProductIds 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( const salesChannelLinks = transform(
{ input, updatedProducts }, { input, updatedProducts },
prepareSalesChannelLinks 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) 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, CreateMoneyAmountDTO,
FilterableMoneyAmountProps, FilterableMoneyAmountProps,
MoneyAmountDTO, MoneyAmountDTO,
UpdateMoneyAmountDTO,
} from "./money-amount" } from "./money-amount"
export interface PricingRepositoryService { export interface PricingRepositoryService {
@@ -227,6 +228,18 @@ export interface CreatePricesDTO extends CreateMoneyAmountDTO {
rules?: CreatePriceSetPriceRules 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 * @interface
* *