From 31449972ed870186d61293c593ee1425e72187c1 Mon Sep 17 00:00:00 2001 From: Stevche Radevski Date: Wed, 31 Jul 2024 15:34:27 +0200 Subject: [PATCH] fix: Use region name in product pricing exports (#8373) * fix:Bug fixes to product import * fix:Add an export failed notification if an export fails * fix: Use region name in product export prices --- .../admin/__fixtures__/prices-with-region.csv | 2 + .../product/admin/product-export.spec.ts | 80 ++++++++++++++++--- .../product/admin/product-import.spec.ts | 2 +- .../product/helpers/normalize-for-export.ts | 38 +++++++-- .../product/helpers/normalize-for-import.ts | 20 ++++- .../src/product/steps/generate-product-csv.ts | 17 +++- .../src/product/workflows/export-products.ts | 18 ++++- .../src/services/product-module-service.ts | 2 +- 8 files changed, 157 insertions(+), 22 deletions(-) create mode 100644 integration-tests/http/__tests__/product/admin/__fixtures__/prices-with-region.csv diff --git a/integration-tests/http/__tests__/product/admin/__fixtures__/prices-with-region.csv b/integration-tests/http/__tests__/product/admin/__fixtures__/prices-with-region.csv new file mode 100644 index 0000000000..cd3c49388a --- /dev/null +++ b/integration-tests/http/__tests__/product/admin/__fixtures__/prices-with-region.csv @@ -0,0 +1,2 @@ +Product Id,Product Title,Product Subtitle,Product Status,Product External Id,Product Description,Product Handle,Product Is Giftcard,Product Discountable,Product Thumbnail,Product Collection Id,Product Type Id,Product Weight,Product Length,Product Height,Product Width,Product Hs Code,Product Origin Country,Product Mid Code,Product Material,Product Created At,Product Updated At,Product Deleted At,Product Image 1,Product Image 2,Product Tag 1,Product Tag 2,Variant Id,Variant Title,Variant Sku,Variant Barcode,Variant Ean,Variant Upc,Variant Allow Backorder,Variant Manage Inventory,Variant Hs Code,Variant Origin Country,Variant Mid Code,Variant Material,Variant Weight,Variant Length,Variant Height,Variant Width,Variant Metadata,Variant Variant Rank,Variant Product Id,Variant Created At,Variant Updated At,Variant Deleted At,Variant Price USD,Variant Price Test Region [USD],Variant Option 1 Name,Variant Option 1 Value,Variant Option 2 Name,Variant Option 2 Value +prod_01J44BH2MFG9MM3EPEQ1SEBDCV,Product with prices,,draft,,test-product-description,product-with-prices,false,true,test-image.png,,,,,,,,,,,2024-07-31T12:16:37.517Z,2024-07-31T12:16:37.517Z,,test-image.png,test-image-2.png,123,456,variant_01J44BH2MXDYM80A5VCAMX17XH,Test variant,,,,,false,true,,,,,,,,,,0,prod_01J44BH2MFG9MM3EPEQ1SEBDCV,2024-07-31T12:16:37.533Z,2024-07-31T12:16:37.533Z,,100,45,size,large,color,green \ No newline at end of file diff --git a/integration-tests/http/__tests__/product/admin/product-export.spec.ts b/integration-tests/http/__tests__/product/admin/product-export.spec.ts index 3350012672..241af315e0 100644 --- a/integration-tests/http/__tests__/product/admin/product-export.spec.ts +++ b/integration-tests/http/__tests__/product/admin/product-export.spec.ts @@ -17,6 +17,7 @@ const compareCSVs = async (filePath, expectedFilePath) => { let fixturesContent = await fs.readFile(expectedFilePath, { encoding: "utf-8", }) + await fs.rm(path.dirname(asLocalPath), { recursive: true, force: true }) // Normalize csv data to get rid of dynamic data const idsToReplace = ["prod_", "pcol_", "variant_", "ptyp_"] @@ -47,6 +48,7 @@ medusaIntegrationTestRunner({ let publishedCollection let baseType + let baseRegion let eventBus: IEventBusModuleService beforeAll(async () => { @@ -56,6 +58,17 @@ medusaIntegrationTestRunner({ beforeEach(async () => { await createAdminUser(dbConnection, adminHeaders, getContainer()) + baseRegion = ( + await api.post( + "/admin/regions", + { + name: "Test region", + currency_code: "USD", + }, + adminHeaders + ) + ).data.region + baseCollection = ( await api.post( "/admin/collections", @@ -196,10 +209,64 @@ medusaIntegrationTestRunner({ notifications[0].data.file.url, path.join(__dirname, "__fixtures__", "exported-products-comma.csv") ) - await fs.rm(path.dirname(notifications[0].data.file.url), { - force: true, - recursive: true, - }) + }) + + it("should export a csv file with region prices", async () => { + const subscriberExecution = TestEventUtils.waitSubscribersExecution( + "notification.notification.created", + eventBus + ) + + const productWithRegionPrices = ( + await api.post( + "/admin/products", + getProductFixture({ + title: "Product with prices", + variants: [ + { + title: "Test variant", + prices: [ + { + currency_code: "usd", + amount: 100, + }, + { + currency_code: "usd", + rules: { + region_id: baseRegion.id, + }, + amount: 45, + }, + ], + options: { + size: "large", + color: "green", + }, + }, + ], + }), + adminHeaders + ) + ).data.product + + const batchJobRes = await api.post( + "/admin/products/export?id=" + productWithRegionPrices.id, + {}, + adminHeaders + ) + + const transactionId = batchJobRes.data.transaction_id + expect(transactionId).toBeTruthy() + + await subscriberExecution + const notifications = ( + await api.get("/admin/notifications", adminHeaders) + ).data.notifications + + await compareCSVs( + notifications[0].data.file.url, + path.join(__dirname, "__fixtures__", "prices-with-region.csv") + ) }) it("should export a csv file filtered by specific products", async () => { @@ -229,11 +296,6 @@ medusaIntegrationTestRunner({ notifications[0].data.file.url, path.join(__dirname, "__fixtures__", "filtered-products.csv") ) - - await fs.rm(path.dirname(notifications[0].data.file.url), { - force: true, - recursive: true, - }) }) }) }, 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 371d56f844..d3e7006d8a 100644 --- a/integration-tests/http/__tests__/product/admin/product-import.spec.ts +++ b/integration-tests/http/__tests__/product/admin/product-import.spec.ts @@ -654,7 +654,7 @@ medusaIntegrationTestRunner({ title: "Test variant", sku: "test-sku-1-1", barcode: "test-barcode-1-1", - allow_backorder: true, + allow_backorder: false, manage_inventory: true, prices: [ expect.objectContaining({ diff --git a/packages/core/core-flows/src/product/helpers/normalize-for-export.ts b/packages/core/core-flows/src/product/helpers/normalize-for-export.ts index 8b9e20ed57..73ce602dda 100644 --- a/packages/core/core-flows/src/product/helpers/normalize-for-export.ts +++ b/packages/core/core-flows/src/product/helpers/normalize-for-export.ts @@ -1,10 +1,19 @@ -import { BigNumberInput, HttpTypes, PricingTypes } from "@medusajs/types" -import { upperCaseFirst } from "@medusajs/utils" +import { + RegionTypes, + BigNumberInput, + HttpTypes, + PricingTypes, +} from "@medusajs/types" +import { MedusaError, upperCaseFirst } from "@medusajs/utils" // We want to have one row per variant, so we need to normalize the data export const normalizeForExport = ( - product: HttpTypes.AdminProduct[] + product: HttpTypes.AdminProduct[], + { regions }: { regions: RegionTypes.RegionDTO[] } ): object[] => { + // Currently region names are treated as case-insensitive. + const regionsMap = new Map(regions.map((r) => [r.id, r])) + const res = product.reduce((acc: object[], product) => { const variants = product.variants ?? [] if (!variants.length) { @@ -15,7 +24,7 @@ export const normalizeForExport = ( variants.forEach((v) => { const toPush = { ...normalizeProductForExport(product), - ...normalizeVariantForExport(v), + ...normalizeVariantForExport(v, regionsMap), } as any delete toPush["Product Variants"] @@ -81,16 +90,31 @@ const normalizeProductForExport = (product: HttpTypes.AdminProduct): object => { const normalizeVariantForExport = ( variant: HttpTypes.AdminProductVariant & { price_set?: PricingTypes.PriceSetDTO - } + }, + regionsMap: Map ): object => { const flattenedPrices = variant.price_set?.prices ?.sort((a, b) => b.currency_code!.localeCompare(a.currency_code!)) .reduce((acc: Record, price) => { const regionRule = price.price_rules?.find( - (r) => r.attribute === "region" + (r) => r.attribute === "region_id" ) + if (regionRule) { - acc[beautifyKey(`variant_price_${regionRule.value}`)] = price.amount! + const region = regionsMap.get(regionRule?.value!) + if (!region) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Region with id ${regionRule?.value} not found` + ) + } + + const regionKey = `variant_price_${region.name + .toLowerCase() + .split(" ") + .join("_")}_[${region.currency_code.toUpperCase()}]` + + acc[beautifyKey(regionKey)] = price.amount! } else if (!price.price_rules?.length) { acc[ beautifyKey(`variant_price_${price.currency_code!.toUpperCase()}`) diff --git a/packages/core/core-flows/src/product/helpers/normalize-for-import.ts b/packages/core/core-flows/src/product/helpers/normalize-for-import.ts index e2801556a8..34701ce0ee 100644 --- a/packages/core/core-flows/src/product/helpers/normalize-for-import.ts +++ b/packages/core/core-flows/src/product/helpers/normalize-for-import.ts @@ -70,6 +70,7 @@ const variantFieldsToOmit = new Map([["variant_product_id", true]]) // These fields can have a numeric value, but they are stored as string in the DB so we need to normalize them const stringFields = [ "product_tag_", + "variant_option_", "variant_barcode", "variant_sku", "variant_ean", @@ -78,6 +79,12 @@ const stringFields = [ "variant_mid_code", ] +const booleanFields = [ + "product_discountable", + "variant_manage_inventory", + "variant_allow_backorder", +] + const normalizeProductForImport = ( rawProduct: object ): HttpTypes.AdminCreateProduct => { @@ -221,9 +228,20 @@ const normalizeVariantForImport = ( } const getNormalizedValue = (key: string, value: any): any => { - return stringFields.some((field) => key.startsWith(field)) + let res = stringFields.some((field) => key.startsWith(field)) ? value?.toString() : value + + if (booleanFields.some((field) => key.startsWith(field))) { + if (value === "TRUE") { + res = true + } + if (value === "FALSE") { + res = false + } + } + + return res } const snakecaseKey = (key: string): string => { diff --git a/packages/core/core-flows/src/product/steps/generate-product-csv.ts b/packages/core/core-flows/src/product/steps/generate-product-csv.ts index f9d5a8decc..0e5c3bcd69 100644 --- a/packages/core/core-flows/src/product/steps/generate-product-csv.ts +++ b/packages/core/core-flows/src/product/steps/generate-product-csv.ts @@ -1,4 +1,8 @@ -import { IFileModuleService, HttpTypes } from "@medusajs/types" +import { + IFileModuleService, + HttpTypes, + IRegionModuleService, +} from "@medusajs/types" import { ModuleRegistrationName, convertJsonToCsv } from "@medusajs/utils" import { StepResponse, createStep } from "@medusajs/workflows-sdk" import { normalizeForExport } from "../helpers/normalize-for-export" @@ -7,7 +11,16 @@ export const generateProductCsvStepId = "generate-product-csv" export const generateProductCsvStep = createStep( generateProductCsvStepId, async (products: HttpTypes.AdminProduct[], { container }) => { - const normalizedData = normalizeForExport(products) + const regionService = container.resolve( + ModuleRegistrationName.REGION + ) + + const regions = await regionService.listRegions( + {}, + { select: ["id", "name", "currency_code"], take: null } + ) + + const normalizedData = normalizeForExport(products, { regions }) const csvContent = convertJsonToCsv(normalizedData) const fileModule: IFileModuleService = container.resolve( diff --git a/packages/core/core-flows/src/product/workflows/export-products.ts b/packages/core/core-flows/src/product/workflows/export-products.ts index 3ecbc7bc2e..07d492e7ed 100644 --- a/packages/core/core-flows/src/product/workflows/export-products.ts +++ b/packages/core/core-flows/src/product/workflows/export-products.ts @@ -6,7 +6,7 @@ import { import { WorkflowTypes } from "@medusajs/types" import { generateProductCsvStep, getAllProductsStep } from "../steps" import { useRemoteQueryStep } from "../../common" -import { sendNotificationsStep } from "../../notification" +import { notifyOnFailureStep, sendNotificationsStep } from "../../notification" export const exportProductsWorkflowId = "export-products" export const exportProductsWorkflow = createWorkflow( @@ -19,6 +19,22 @@ export const exportProductsWorkflow = createWorkflow( backgroundExecution: true, }) + const failureNotification = transform({ input }, (data) => { + return [ + { + // We don't need the recipient here for now, but if we want to push feed notifications to a specific user we could add it. + to: "", + channel: "feed", + template: "admin-ui", + data: { + title: "Product export", + description: `Failed to export products, please try again later.`, + }, + }, + ] + }) + notifyOnFailureStep(failureNotification) + const file = generateProductCsvStep(products) const fileDetails = useRemoteQueryStep({ fields: ["id", "url"], diff --git a/packages/modules/product/src/services/product-module-service.ts b/packages/modules/product/src/services/product-module-service.ts index b3029cf400..2384d12ff1 100644 --- a/packages/modules/product/src/services/product-module-service.ts +++ b/packages/modules/product/src/services/product-module-service.ts @@ -1571,7 +1571,7 @@ export default class ProductModuleService if (productData.handle && !isValidHandle(productData.handle)) { throw new MedusaError( MedusaError.Types.INVALID_DATA, - "Invalid product handle. It must contain URL safe characters" + `Invalid product handle '${productData.handle}'. It must contain URL safe characters` ) } }