diff --git a/integration-tests/http/__tests__/product/admin/product-import.spec.ts b/integration-tests/http/__tests__/product/admin/product-import.spec.ts index 873a507206..5fc1723c92 100644 --- a/integration-tests/http/__tests__/product/admin/product-import.spec.ts +++ b/integration-tests/http/__tests__/product/admin/product-import.spec.ts @@ -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", diff --git a/integration-tests/http/__tests__/product/admin/product.spec.ts b/integration-tests/http/__tests__/product/admin/product.spec.ts index 976c8fe496..14578f4200 100644 --- a/integration-tests/http/__tests__/product/admin/product.spec.ts +++ b/integration-tests/http/__tests__/product/admin/product.spec.ts @@ -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: [], diff --git a/packages/core/core-flows/src/product/steps/get-variant-ids-for-products.ts b/packages/core/core-flows/src/product/steps/get-variant-ids-for-products.ts new file mode 100644 index 0000000000..7e1c053017 --- /dev/null +++ b/packages/core/core-flows/src/product/steps/get-variant-ids-for-products.ts @@ -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( + 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)) + ) + } +) diff --git a/packages/core/core-flows/src/product/steps/group-products-for-batch.ts b/packages/core/core-flows/src/product/steps/group-products-for-batch.ts index 3d1c1c49f9..3b860ed171 100644 --- a/packages/core/core-flows/src/product/steps/group-products-for-batch.ts +++ b/packages/core/core-flows/src/product/steps/group-products-for-batch.ts @@ -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 } ) diff --git a/packages/core/core-flows/src/product/steps/wait-confirmation-product-import.ts b/packages/core/core-flows/src/product/steps/wait-confirmation-product-import.ts index b1e3af9834..0032c0eab9 100644 --- a/packages/core/core-flows/src/product/steps/wait-confirmation-product-import.ts +++ b/packages/core/core-flows/src/product/steps/wait-confirmation-product-import.ts @@ -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 () => {} ) diff --git a/packages/core/core-flows/src/product/workflows/index.ts b/packages/core/core-flows/src/product/workflows/index.ts index 52ae012987..1eba8091fb 100644 --- a/packages/core/core-flows/src/product/workflows/index.ts +++ b/packages/core/core-flows/src/product/workflows/index.ts @@ -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" diff --git a/packages/core/core-flows/src/product/workflows/update-products.ts b/packages/core/core-flows/src/product/workflows/update-products.ts index 0c8059fe07..c5dad99fc6 100644 --- a/packages/core/core-flows/src/product/workflows/update-products.ts +++ b/packages/core/core-flows/src/product/workflows/update-products.ts @@ -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 & { sales_channels?: { id: string }[] + variants?: UpdateProductVariantWorkflowInputDTO[] } } type UpdateProductsStepInputProducts = { - products: (ProductTypes.UpsertProductDTO & { + products: (Omit & { 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 ): WorkflowData => { - // 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) diff --git a/packages/core/core-flows/src/product/workflows/upsert-variant-prices.ts b/packages/core/core-flows/src/product/workflows/upsert-variant-prices.ts new file mode 100644 index 0000000000..d1bed23686 --- /dev/null +++ b/packages/core/core-flows/src/product/workflows/upsert-variant-prices.ts @@ -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): WorkflowData => { + 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) + } +) diff --git a/packages/core/types/src/pricing/common/price-set.ts b/packages/core/types/src/pricing/common/price-set.ts index 486533e8ad..76ca0f8012 100644 --- a/packages/core/types/src/pricing/common/price-set.ts +++ b/packages/core/types/src/pricing/common/price-set.ts @@ -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 *