From c025074aca10fb04674112cd373c50b77566d0ae Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Wed, 27 Jul 2022 19:11:09 +0200 Subject: [PATCH] feat(medusa): Includes Sales channels as part of the product/order export (#1882) **What** Add support to sales channel in the product/order export strategy **How** Update the strategy to include the sales channel if the flag is enabled **Tests** Add new unit tests that check that the exported data does include the appropriate sales channel when the flag is enabled and that the data still does not include the sales channel if the flag is not including the flag FIXES CORE-303 --- .../__fixtures__/order-export-data.ts | 11 + .../__fixtures__/product-export-data.ts | 7 + .../order/__snapshots__/order-export.ts.snap | 11 + .../batch-jobs/order/order-export.ts | 293 ++++++++++++---- .../product/__snapshots__/export.ts.snap | 13 + .../__tests__/batch-jobs/product/export.ts | 332 +++++++++++++----- .../src/strategies/batch-jobs/order/export.ts | 37 +- .../strategies/batch-jobs/product/export.ts | 145 ++++++-- .../strategies/batch-jobs/product/index.ts | 1 + 9 files changed, 653 insertions(+), 197 deletions(-) diff --git a/packages/medusa/src/strategies/__fixtures__/order-export-data.ts b/packages/medusa/src/strategies/__fixtures__/order-export-data.ts index c993150f1b..2c5fd5209c 100644 --- a/packages/medusa/src/strategies/__fixtures__/order-export-data.ts +++ b/packages/medusa/src/strategies/__fixtures__/order-export-data.ts @@ -1,4 +1,5 @@ import { DeepPartial } from "typeorm" +import { IdMap } from "medusa-test-utils" import { FulfillmentStatus, Order, @@ -39,6 +40,11 @@ export const ordersToExport: DeepPartial[] = [ last_name: "Doe", email: "John@Doe.com", }, + sales_channel: { + id: IdMap.getId("sc_1"), + name: "SC 1", + description: "SC 1", + }, }, { id: "order_2", @@ -70,5 +76,10 @@ export const ordersToExport: DeepPartial[] = [ last_name: "Doe", email: "Jane@Doe.com", }, + sales_channel: { + id: IdMap.getId("sc_2"), + name: "SC 2", + description: "SC 2", + }, }, ] diff --git a/packages/medusa/src/strategies/__fixtures__/product-export-data.ts b/packages/medusa/src/strategies/__fixtures__/product-export-data.ts index 1403170edf..9e2ef61887 100644 --- a/packages/medusa/src/strategies/__fixtures__/product-export-data.ts +++ b/packages/medusa/src/strategies/__fixtures__/product-export-data.ts @@ -11,6 +11,9 @@ const variantIds = [ ] export const productsToExport = [ { + sales_channels: [ + { id: IdMap.getId("sc_1"), name: "SC 1", description: "SC 1" }, + ], collection: { created_at: "randomString", deleted_at: null, @@ -202,6 +205,10 @@ export const productsToExport = [ width: null, }, { + sales_channels: [ + { id: IdMap.getId("sc_1"), name: "SC 1", description: "SC 1" }, + { id: IdMap.getId("sc_2"), name: "SC 2", description: "SC 2" }, + ], collection: { created_at: "randomString", deleted_at: null, diff --git a/packages/medusa/src/strategies/__tests__/batch-jobs/order/__snapshots__/order-export.ts.snap b/packages/medusa/src/strategies/__tests__/batch-jobs/order/__snapshots__/order-export.ts.snap index 150d2d471e..6cc44b37c8 100644 --- a/packages/medusa/src/strategies/__tests__/batch-jobs/order/__snapshots__/order-export.ts.snap +++ b/packages/medusa/src/strategies/__tests__/batch-jobs/order/__snapshots__/order-export.ts.snap @@ -10,3 +10,14 @@ Array [ ", ] `; + +exports[`Order export strategy with sales channel should process the batch job and generate the appropriate output 1`] = ` +Array [ + "Order_ID;Display_ID;Order status;Date;Customer First name;Customer Last name;Customer Email;Customer ID;Shipping Address 1;Shipping Address 2;Shipping Country Code;Shipping City;Shipping Postal Code;Shipping Region ID;Fulfillment Status;Payment Status;Subtotal;Shipping Total;Discount Total;Gift Card Total;Refunded Total;Tax Total;Total;Currency Code;Sales channel name;Sales channel description +", + "order_1;123;pending;Tue, 01 Jan 2019 00:00:00 GMT;John;Doe;John@Doe.com;customer_1;123 Main St;;US;New York;10001;region_1;partially_fulfilled;captured;10;10;0;0;0;5;25;usd;SC 1;SC 1 +", + "order_2;124;completed;Tue, 01 Jan 2019 00:00:00 GMT;Jane;Doe;Jane@Doe.com;customer_2;Hovedgaden 1;;DK;Copenhagen;1150;region_2;fulfilled;captured;125;10;0;0;0;0;135;eur;SC 2;SC 2 +", +] +`; diff --git a/packages/medusa/src/strategies/__tests__/batch-jobs/order/order-export.ts b/packages/medusa/src/strategies/__tests__/batch-jobs/order/order-export.ts index e7495d5bfe..74e141665a 100644 --- a/packages/medusa/src/strategies/__tests__/batch-jobs/order/order-export.ts +++ b/packages/medusa/src/strategies/__tests__/batch-jobs/order/order-export.ts @@ -3,81 +3,9 @@ import { IdMap, MockManager } from "medusa-test-utils" import { User } from "../../../../models" import { BatchJobStatus } from "../../../../types/batch-job" import { ordersToExport } from "../../../__fixtures__/order-export-data" +import { FlagRouter } from "../../../../utils/flag-router"; +import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels"; -const outputDataStorage: string[] = [] - -let fakeJob = { - id: IdMap.getId("order-export-job"), - type: "order-export", - context: { - params: {}, - list_config: { - select: [ - "id", - "display_id", - "status", - "created_at", - "fulfillment_status", - "payment_status", - "subtotal", - "shipping_total", - "discount_total", - "gift_card_total", - "refunded_total", - "tax_total", - "total", - "currency_code", - "region_id", - ], - relations: ["customer", "shipping_address"], - }, - }, - created_by: IdMap.getId("order-export-job-creator"), - created_by_user: {} as User, - result: {}, - dry_run: false, - status: BatchJobStatus.PROCESSING, -} - -const fileServiceMock = { - delete: jest.fn(), - withTransaction: function () { - return this - }, - getUploadStreamDescriptor: jest.fn().mockImplementation(() => { - return Promise.resolve({ - writeStream: { - write: (data: string) => { - outputDataStorage.push(data) - }, - end: () => void 0, - }, - promise: Promise.resolve(), - fileKey: "order-export.csv", - }) - }), -} -const batchJobServiceMock = { - withTransaction: function (): any { - return this - }, - update: jest.fn().mockImplementation(async (job, data) => { - fakeJob = { - ...fakeJob, - ...data, - context: { ...fakeJob?.context, ...data?.context }, - result: { ...fakeJob?.result, ...data?.result } - } - return Promise.resolve(fakeJob) - }), - complete: jest.fn().mockImplementation(async () => { - fakeJob.status = BatchJobStatus.COMPLETED - return fakeJob - }), - retrieve: jest.fn().mockImplementation(async () => { - return fakeJob - }), -} const orderServiceMock = { withTransaction: function (): any { return this @@ -100,11 +28,87 @@ const orderServiceWithoutDataMock = { } describe("Order export strategy", () => { + const outputDataStorage: string[] = [] + + let fakeJob = { + id: IdMap.getId("order-export-job"), + type: "order-export", + context: { + params: {}, + list_config: { + select: [ + "id", + "display_id", + "status", + "created_at", + "fulfillment_status", + "payment_status", + "subtotal", + "shipping_total", + "discount_total", + "gift_card_total", + "refunded_total", + "tax_total", + "total", + "currency_code", + "region_id", + ], + relations: ["customer", "shipping_address"], + }, + }, + created_by: IdMap.getId("order-export-job-creator"), + created_by_user: {} as User, + result: {}, + dry_run: false, + status: BatchJobStatus.PROCESSING, + } + + const fileServiceMock = { + delete: jest.fn(), + withTransaction: function () { + return this + }, + getUploadStreamDescriptor: jest.fn().mockImplementation(() => { + return Promise.resolve({ + writeStream: { + write: (data: string) => { + outputDataStorage.push(data) + }, + end: () => void 0, + }, + promise: Promise.resolve(), + fileKey: "order-export.csv", + }) + }), + } + const batchJobServiceMock = { + withTransaction: function (): any { + return this + }, + update: jest.fn().mockImplementation(async (job, data) => { + fakeJob = { + ...fakeJob, + ...data, + context: { ...fakeJob?.context, ...data?.context }, + result: { ...fakeJob?.result, ...data?.result } + } + return Promise.resolve(fakeJob) + }), + complete: jest.fn().mockImplementation(async () => { + fakeJob.status = BatchJobStatus.COMPLETED + return fakeJob + }), + retrieve: jest.fn().mockImplementation(async () => { + return fakeJob + }), + } + const orderExportStrategy = new OrderExportStrategy({ batchJobService: batchJobServiceMock as any, fileService: fileServiceMock as any, orderService: orderServiceMock as any, manager: MockManager, + featureFlagRouter: new FlagRouter({}), }) it("Should generate header as template", async () => { @@ -150,6 +154,7 @@ describe("Order export strategy", () => { fileService: fileServiceMock as any, orderService: orderServiceWithoutDataMock as any, manager: MockManager, + featureFlagRouter: new FlagRouter({}), }) await orderExportStrategy.processJob(fakeJob.id) @@ -157,3 +162,143 @@ describe("Order export strategy", () => { expect((fakeJob.result as any).file_key).toBeDefined() }) }) + +describe("Order export strategy with sales channel", () => { + const outputDataStorage: string[] = [] + + let fakeJob = { + id: IdMap.getId("order-export-job"), + type: "order-export", + context: { + params: {}, + list_config: { + select: [ + "id", + "display_id", + "status", + "created_at", + "fulfillment_status", + "payment_status", + "subtotal", + "shipping_total", + "discount_total", + "gift_card_total", + "refunded_total", + "tax_total", + "total", + "currency_code", + "region_id", + ], + relations: ["customer", "shipping_address", "sales_channel"], + }, + }, + created_by: IdMap.getId("order-export-job-creator"), + created_by_user: {} as User, + result: {}, + dry_run: false, + status: BatchJobStatus.PROCESSING, + } + + const fileServiceMock = { + delete: jest.fn(), + withTransaction: function () { + return this + }, + getUploadStreamDescriptor: jest.fn().mockImplementation(() => { + return Promise.resolve({ + writeStream: { + write: (data: string) => { + outputDataStorage.push(data) + }, + end: () => void 0, + }, + promise: Promise.resolve(), + fileKey: "order-export.csv", + }) + }), + } + const batchJobServiceMock = { + withTransaction: function (): any { + return this + }, + update: jest.fn().mockImplementation(async (job, data) => { + fakeJob = { + ...fakeJob, + ...data, + context: { ...fakeJob?.context, ...data?.context }, + result: { ...fakeJob?.result, ...data?.result } + } + return Promise.resolve(fakeJob) + }), + complete: jest.fn().mockImplementation(async () => { + fakeJob.status = BatchJobStatus.COMPLETED + return fakeJob + }), + retrieve: jest.fn().mockImplementation(async () => { + return fakeJob + }), + } + + const orderExportStrategy = new OrderExportStrategy({ + batchJobService: batchJobServiceMock as any, + fileService: fileServiceMock as any, + orderService: orderServiceMock as any, + manager: MockManager, + featureFlagRouter: new FlagRouter({ + [SalesChannelFeatureFlag.key]: true, + }), + }) + + it("Should generate header as template", async () => { + const template = await orderExportStrategy.buildTemplate() + expect(template.split(";")).toEqual([ + "Order_ID", + "Display_ID", + "Order status", + "Date", + "Customer First name", + "Customer Last name", + "Customer Email", + "Customer ID", + "Shipping Address 1", + "Shipping Address 2", + "Shipping Country Code", + "Shipping City", + "Shipping Postal Code", + "Shipping Region ID", + "Fulfillment Status", + "Payment Status", + "Subtotal", + "Shipping Total", + "Discount Total", + "Gift Card Total", + "Refunded Total", + "Tax Total", + "Total", + "Currency Code", + "Sales channel name", + "Sales channel description\r\n", + ]) + }) + + it("should process the batch job and generate the appropriate output", async () => { + await orderExportStrategy.processJob(fakeJob.id) + + expect(outputDataStorage).toMatchSnapshot() + expect((fakeJob.result as any).file_key).toBeDefined() + }) + + it("should always provide a file_key even with no data", async () => { + const orderExportStrategy = new OrderExportStrategy({ + batchJobService: batchJobServiceMock as any, + fileService: fileServiceMock as any, + orderService: orderServiceWithoutDataMock as any, + manager: MockManager, + featureFlagRouter: new FlagRouter({}), + }) + + await orderExportStrategy.processJob(fakeJob.id) + + expect((fakeJob.result as any).file_key).toBeDefined() + }) +}) \ No newline at end of file diff --git a/packages/medusa/src/strategies/__tests__/batch-jobs/product/__snapshots__/export.ts.snap b/packages/medusa/src/strategies/__tests__/batch-jobs/product/__snapshots__/export.ts.snap index 537bc939a9..e2ba7d7ad4 100644 --- a/packages/medusa/src/strategies/__tests__/batch-jobs/product/__snapshots__/export.ts.snap +++ b/packages/medusa/src/strategies/__tests__/batch-jobs/product/__snapshots__/export.ts.snap @@ -12,3 +12,16 @@ Array [ ", ] `; + +exports[`Product export strategy with sales channels should process the batch job and generate the appropriate output 1`] = ` +Array [ + "Product ID;Product Handle;Product Title;Product Subtitle;Product Description;Product Status;Product Thumbnail;Product Weight;Product Length;Product Width;Product Height;Product HS Code;Product Origin Country;Product MID Code;Product Material;Product Collection Title;Product Collection Handle;Product Type;Product Tags;Product Discountable;Product External ID;Product Profile Name;Product Profile Type;Variant ID;Variant Title;Variant SKU;Variant Barcode;Variant Inventory Quantity;Variant Allow backorder;Variant Manage inventory;Variant Weight;Variant Length;Variant Width;Variant Height;Variant HS Code;Variant Origin Country;Variant MID Code;Variant Material;Price france [USD];Price USD;Price denmark [DKK];Price Denmark [DKK];Option 1 Name;Option 1 Value;Option 2 Name;Option 2 Value;Image 1 Url;Sales channel 1 Name;Sales channel 1 Description;Sales channel 2 Name;Sales channel 2 Description +", + "product-export-strategy-product-1;test-product-product-1;Test product;;test-product-description-1;draft;;;;;;;;;;Test collection 1;test-collection1;test-type-1;123_1;true;;profile_1;profile_type_1;product-export-strategy-variant-1;Test variant;test-sku;test-barcode;10;false;true;;;;;;;;;100;110;130;;test-option-1;option 1 value 1;test-option-2;option 2 value 1;test-image.png;SC 1;SC 1;; +", + "product-export-strategy-product-2;test-product-product-2;Test product;;test-product-description;draft;;;;;;;;;;Test collection;test-collection2;test-type;123;true;;profile_2;profile_type_2;product-export-strategy-variant-2;Test variant;test-sku;test-barcode;10;false;true;;;;;;;;;;;;110;test-option;Option 1 value 1;;;test-image.png;SC 1;SC 1;SC 2;SC 2 +", + "product-export-strategy-product-2;test-product-product-2;Test product;;test-product-description;draft;;;;;;;;;;Test collection;test-collection2;test-type;123;true;;profile_2;profile_type_2;product-export-strategy-variant-3;Test variant;test-sku;test-barcode;10;false;true;;;;;;;;;;120;;;test-option;Option 1 Value 1;;;test-image.png;SC 1;SC 1;SC 2;SC 2 +", +] +`; diff --git a/packages/medusa/src/strategies/__tests__/batch-jobs/product/export.ts b/packages/medusa/src/strategies/__tests__/batch-jobs/product/export.ts index 887a238d75..402a4ffc5b 100644 --- a/packages/medusa/src/strategies/__tests__/batch-jobs/product/export.ts +++ b/packages/medusa/src/strategies/__tests__/batch-jobs/product/export.ts @@ -6,87 +6,9 @@ import { productsToExport } from "../../../__fixtures__/product-export-data" import { AdminPostBatchesReq, defaultAdminProductRelations } from "../../../../api" import { ProductExportBatchJob } from "../../../batch-jobs/product" import { Request } from "express" +import { FlagRouter } from "../../../../utils/flag-router"; +import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels"; -const outputDataStorage: string[] = [] - -let fakeJob = { - id: IdMap.getId("product-export-job"), - type: 'product-export', - created_by: IdMap.getId("product-export-job-creator"), - created_by_user: {} as User, - context: {}, - result: {}, - dry_run: false, - status: BatchJobStatus.PROCESSING as BatchJobStatus -} as ProductExportBatchJob - -let canceledFakeJob = { - ...fakeJob, - id: "bj_failed", - status: BatchJobStatus.CANCELED -} as ProductExportBatchJob - -const fileServiceMock = { - delete: jest.fn(), - getUploadStreamDescriptor: jest.fn().mockImplementation(() => { - return Promise.resolve({ - writeStream: { - write: (data: string) => { - outputDataStorage.push(data) - }, - end: () => void 0 - }, - promise: Promise.resolve(), - fileKey: 'product-export.csv' - }) - }), - withTransaction: function () { - return this - } -} -const batchJobServiceMock = { - withTransaction: function () { - return this - }, - update: jest.fn().mockImplementation((jobOrId, data) => { - if ((jobOrId?.id ?? jobOrId) === "bj_failed") { - canceledFakeJob = { - ...canceledFakeJob, - ...data, - context: { ...canceledFakeJob?.context, ...data?.context }, - result: { ...canceledFakeJob?.result, ...data?.result } - } - - return Promise.resolve(canceledFakeJob) - } - - fakeJob = { - ...fakeJob, - ...data, - context: { ...fakeJob?.context, ...data?.context }, - result: { ...fakeJob?.result, ...data?.result } - } - - return Promise.resolve(fakeJob) - }), - updateStatus: jest.fn().mockImplementation((status) => { - fakeJob.status = status - return Promise.resolve(fakeJob) - }), - complete: jest.fn().mockImplementation(() => { - fakeJob.status = BatchJobStatus.COMPLETED - return Promise.resolve(fakeJob) - }), - retrieve: jest.fn().mockImplementation((id) => { - const targetFakeJob = id === "bj_failed" - ? canceledFakeJob - : fakeJob - return Promise.resolve(targetFakeJob) - }), - setFailed: jest.fn().mockImplementation((...args) => { - console.error(...args) - }) -} const productServiceMock = { withTransaction: function () { return this @@ -108,11 +30,92 @@ const productServiceWithNoDataMock = { const managerMock = MockManager describe("Product export strategy", () => { + const outputDataStorage: string[] = [] + const fileServiceMock = { + delete: jest.fn(), + getUploadStreamDescriptor: jest.fn().mockImplementation(() => { + return Promise.resolve({ + writeStream: { + write: (data: string) => { + outputDataStorage.push(data) + }, + end: () => void 0 + }, + promise: Promise.resolve(), + fileKey: 'product-export.csv' + }) + }), + withTransaction: function () { + return this + } + } + let fakeJob = { + id: IdMap.getId("product-export-job"), + type: 'product-export', + created_by: IdMap.getId("product-export-job-creator"), + created_by_user: {} as User, + context: {}, + result: {}, + dry_run: false, + status: BatchJobStatus.PROCESSING as BatchJobStatus + } as ProductExportBatchJob + + let canceledFakeJob = { + ...fakeJob, + id: "bj_failed", + status: BatchJobStatus.CANCELED + } as ProductExportBatchJob + + const batchJobServiceMock = { + withTransaction: function () { + return this + }, + update: jest.fn().mockImplementation((jobOrId, data) => { + if ((jobOrId?.id ?? jobOrId) === "bj_failed") { + canceledFakeJob = { + ...canceledFakeJob, + ...data, + context: { ...canceledFakeJob?.context, ...data?.context }, + result: { ...canceledFakeJob?.result, ...data?.result } + } + + return Promise.resolve(canceledFakeJob) + } + + fakeJob = { + ...fakeJob, + ...data, + context: { ...fakeJob?.context, ...data?.context }, + result: { ...fakeJob?.result, ...data?.result } + } + + return Promise.resolve(fakeJob) + }), + updateStatus: jest.fn().mockImplementation((status) => { + fakeJob.status = status + return Promise.resolve(fakeJob) + }), + complete: jest.fn().mockImplementation(() => { + fakeJob.status = BatchJobStatus.COMPLETED + return Promise.resolve(fakeJob) + }), + retrieve: jest.fn().mockImplementation((id) => { + const targetFakeJob = id === "bj_failed" + ? canceledFakeJob + : fakeJob + return Promise.resolve(targetFakeJob) + }), + setFailed: jest.fn().mockImplementation((...args) => { + console.error(...args) + }) + } + const productExportStrategy = new ProductExportStrategy({ manager: managerMock, fileService: fileServiceMock as any, batchJobService: batchJobServiceMock as any, productService: productServiceMock as any, + featureFlagRouter: new FlagRouter({}), }) it('should generate the appropriate template', async () => { @@ -164,6 +167,11 @@ describe("Product export strategy", () => { expect(template).toMatch(/.*Option 2 Name.*/) expect(template).toMatch(/.*Option 2 Value.*/) + expect(template).not.toMatch(/.*Sales channel 1 Name.*/) + expect(template).not.toMatch(/.*Sales channel 1 Description.*/) + expect(template).not.toMatch(/.*Sales channel 2 Name.*/) + expect(template).not.toMatch(/.*Sales channel 2 Description.*/) + expect(template).toMatch(/.*Price USD.*/) expect(template).toMatch(/.*Price france \[USD\].*/) expect(template).toMatch(/.*Price denmark \[DKK\].*/) @@ -242,6 +250,7 @@ describe("Product export strategy", () => { fileService: fileServiceMock as any, productService: productServiceWithNoDataMock as any, manager: MockManager, + featureFlagRouter: new FlagRouter({}), }) await productExportStrategy.prepareBatchJobForProcessing(fakeJob, {} as Request) @@ -257,6 +266,7 @@ describe("Product export strategy", () => { fileService: fileServiceMock as any, productService: productServiceMock as any, manager: MockManager, + featureFlagRouter: new FlagRouter({}), }) await productExportStrategy.prepareBatchJobForProcessing(canceledFakeJob, {} as Request) @@ -267,3 +277,165 @@ describe("Product export strategy", () => { expect((canceledFakeJob.result as any).file_size).not.toBeDefined() }) }) + +describe("Product export strategy with sales channels", () => { + const outputDataStorage: string[] = [] + const fileServiceMock = { + delete: jest.fn(), + getUploadStreamDescriptor: jest.fn().mockImplementation(() => { + return Promise.resolve({ + writeStream: { + write: (data: string) => { + outputDataStorage.push(data) + }, + end: () => void 0 + }, + promise: Promise.resolve(), + fileKey: 'product-export.csv' + }) + }), + withTransaction: function () { + return this + } + } + let fakeJob = { + id: IdMap.getId("product-export-job"), + type: 'product-export', + created_by: IdMap.getId("product-export-job-creator"), + created_by_user: {} as User, + context: {}, + result: {}, + dry_run: false, + status: BatchJobStatus.PROCESSING as BatchJobStatus + } as ProductExportBatchJob + + let canceledFakeJob = { + ...fakeJob, + id: "bj_failed", + status: BatchJobStatus.CANCELED + } as ProductExportBatchJob + + const batchJobServiceMock = { + withTransaction: function () { + return this + }, + update: jest.fn().mockImplementation((jobOrId, data) => { + if ((jobOrId?.id ?? jobOrId) === "bj_failed") { + canceledFakeJob = { + ...canceledFakeJob, + ...data, + context: { ...canceledFakeJob?.context, ...data?.context }, + result: { ...canceledFakeJob?.result, ...data?.result } + } + + return Promise.resolve(canceledFakeJob) + } + + fakeJob = { + ...fakeJob, + ...data, + context: { ...fakeJob?.context, ...data?.context }, + result: { ...fakeJob?.result, ...data?.result } + } + + return Promise.resolve(fakeJob) + }), + updateStatus: jest.fn().mockImplementation((status) => { + fakeJob.status = status + return Promise.resolve(fakeJob) + }), + complete: jest.fn().mockImplementation(() => { + fakeJob.status = BatchJobStatus.COMPLETED + return Promise.resolve(fakeJob) + }), + retrieve: jest.fn().mockImplementation((id) => { + const targetFakeJob = id === "bj_failed" + ? canceledFakeJob + : fakeJob + return Promise.resolve(targetFakeJob) + }), + setFailed: jest.fn().mockImplementation((...args) => { + console.error(...args) + }) + } + + const productExportStrategy = new ProductExportStrategy({ + manager: managerMock, + fileService: fileServiceMock as any, + batchJobService: batchJobServiceMock as any, + productService: productServiceMock as any, + featureFlagRouter: new FlagRouter({ + [SalesChannelFeatureFlag.key]: true, + }), + }) + + it('should generate the appropriate template', async () => { + await productExportStrategy.prepareBatchJobForProcessing(fakeJob, {} as Request) + await productExportStrategy.preProcessBatchJob(fakeJob.id) + const template = await productExportStrategy.buildHeader(fakeJob) + expect(template).toMatch(/.*Product ID.*/) + expect(template).toMatch(/.*Product Handle.*/) + expect(template).toMatch(/.*Product Title.*/) + expect(template).toMatch(/.*Product Subtitle.*/) + expect(template).toMatch(/.*Product Description.*/) + expect(template).toMatch(/.*Product Status.*/) + expect(template).toMatch(/.*Product Thumbnail.*/) + expect(template).toMatch(/.*Product Weight.*/) + expect(template).toMatch(/.*Product Length.*/) + expect(template).toMatch(/.*Product Width.*/) + expect(template).toMatch(/.*Product Height.*/) + expect(template).toMatch(/.*Product HS Code.*/) + expect(template).toMatch(/.*Product Origin Country.*/) + expect(template).toMatch(/.*Product MID Code.*/) + expect(template).toMatch(/.*Product Material.*/) + expect(template).toMatch(/.*Product Collection Title.*/) + expect(template).toMatch(/.*Product Collection Handle.*/) + expect(template).toMatch(/.*Product Type.*/) + expect(template).toMatch(/.*Product Tags.*/) + expect(template).toMatch(/.*Product Discountable.*/) + expect(template).toMatch(/.*Product External ID.*/) + expect(template).toMatch(/.*Product Profile Name.*/) + expect(template).toMatch(/.*Product Profile Type.*/) + expect(template).toMatch(/.*Product Profile Type.*/) + + expect(template).toMatch(/.*Variant ID.*/) + expect(template).toMatch(/.*Variant Title.*/) + expect(template).toMatch(/.*Variant SKU.*/) + expect(template).toMatch(/.*Variant Barcode.*/) + expect(template).toMatch(/.*Variant Allow backorder.*/) + expect(template).toMatch(/.*Variant Manage inventory.*/) + expect(template).toMatch(/.*Variant Weight.*/) + expect(template).toMatch(/.*Variant Length.*/) + expect(template).toMatch(/.*Variant Width.*/) + expect(template).toMatch(/.*Variant Height.*/) + expect(template).toMatch(/.*Variant HS Code.*/) + expect(template).toMatch(/.*Variant Origin Country.*/) + expect(template).toMatch(/.*Variant MID Code.*/) + expect(template).toMatch(/.*Variant Material.*/) + + expect(template).toMatch(/.*Option 1 Name.*/) + expect(template).toMatch(/.*Option 1 Value.*/) + expect(template).toMatch(/.*Option 2 Name.*/) + expect(template).toMatch(/.*Option 2 Value.*/) + + expect(template).toMatch(/.*Price USD.*/) + expect(template).toMatch(/.*Price france \[USD\].*/) + expect(template).toMatch(/.*Price denmark \[DKK\].*/) + expect(template).toMatch(/.*Price Denmark \[DKK\].*/) + + expect(template).toMatch(/.*Sales channel 1 Name.*/) + expect(template).toMatch(/.*Sales channel 1 Description.*/) + expect(template).toMatch(/.*Sales channel 2 Name.*/) + expect(template).toMatch(/.*Sales channel 2 Description.*/) + + expect(template).toMatch(/.*Image 1 Url.*/) + }) + + it('should process the batch job and generate the appropriate output', async () => { + await productExportStrategy.prepareBatchJobForProcessing(fakeJob, {} as Request) + await productExportStrategy.preProcessBatchJob(fakeJob.id) + await productExportStrategy.processJob(fakeJob.id) + expect(outputDataStorage).toMatchSnapshot() + expect((fakeJob.result as any).file_key).toBeDefined() + }) +}) \ No newline at end of file diff --git a/packages/medusa/src/strategies/batch-jobs/order/export.ts b/packages/medusa/src/strategies/batch-jobs/order/export.ts index d8edbd3774..49195615f1 100644 --- a/packages/medusa/src/strategies/batch-jobs/order/export.ts +++ b/packages/medusa/src/strategies/batch-jobs/order/export.ts @@ -5,20 +5,23 @@ import { OrderExportBatchJobContext, orderExportPropertiesDescriptors, } from "." -import { AdminPostBatchesReq } from "../../../api/routes/admin/batch/create-batch-job" +import { AdminPostBatchesReq } from "../../../api" import { IFileService } from "../../../interfaces" -import { AbstractBatchJobStrategy } from "../../../interfaces/batch-job-strategy" +import { AbstractBatchJobStrategy } from "../../../interfaces" import { Order } from "../../../models" import { OrderService } from "../../../services" import BatchJobService from "../../../services/batch-job" import { BatchJobStatus } from "../../../types/batch-job" import { prepareListQuery } from "../../../utils/get-query-config" +import { FlagRouter } from "../../../utils/flag-router" +import SalesChannelFeatureFlag from "../../../loaders/feature-flags/sales-channels" type InjectedDependencies = { - fileService: IFileService + fileService: IFileService orderService: OrderService batchJobService: BatchJobService manager: EntityManager + featureFlagRouter: FlagRouter } class OrderExportStrategy extends AbstractBatchJobStrategy { @@ -36,6 +39,11 @@ class OrderExportStrategy extends AbstractBatchJobStrategy protected readonly fileService_: IFileService protected readonly batchJobService_: BatchJobService protected readonly orderService_: OrderService + protected readonly featureFlagRouter_: FlagRouter + + protected readonly orderExportPropertiesDescriptors = [ + ...orderExportPropertiesDescriptors, + ] protected readonly defaultRelations_ = ["customer", "shipping_address"] protected readonly defaultFields_ = [ @@ -61,6 +69,7 @@ class OrderExportStrategy extends AbstractBatchJobStrategy batchJobService, orderService, manager, + featureFlagRouter, }: InjectedDependencies) { // eslint-disable-next-line prefer-rest-params super(arguments[0]) @@ -69,6 +78,12 @@ class OrderExportStrategy extends AbstractBatchJobStrategy this.fileService_ = fileService this.batchJobService_ = batchJobService this.orderService_ = orderService + this.featureFlagRouter_ = featureFlagRouter + + if (featureFlagRouter.isFeatureEnabled(SalesChannelFeatureFlag.key)) { + this.defaultRelations_.push("sales_channel") + this.addSalesChannelColumns() + } } async prepareBatchJobForProcessing( @@ -253,13 +268,12 @@ class OrderExportStrategy extends AbstractBatchJobStrategy await promise }, "REPEATABLE READ", - async (err: Error) => { + async (err: Error) => this.handleProcessingError(batchJobId, err, { offset, count: orderCount, progress: offset / orderCount, }) - } ) } @@ -270,7 +284,7 @@ class OrderExportStrategy extends AbstractBatchJobStrategy } private buildHeader( - lineDescriptor: OrderDescriptor[] = orderExportPropertiesDescriptors + lineDescriptor: OrderDescriptor[] = this.orderExportPropertiesDescriptors ): string { return ( [...lineDescriptor.map(({ title }) => title)].join(this.DELIMITER) + @@ -293,11 +307,20 @@ class OrderExportStrategy extends AbstractBatchJobStrategy fields: string[], relations: string[] ): OrderDescriptor[] { - return orderExportPropertiesDescriptors.filter( + return this.orderExportPropertiesDescriptors.filter( ({ fieldName }) => fields.indexOf(fieldName) !== -1 || relations.indexOf(fieldName) !== -1 ) } + + private addSalesChannelColumns(): void { + this.orderExportPropertiesDescriptors.push({ + fieldName: "sales_channel", + title: ["Sales channel name", "Sales channel description"].join(";"), + accessor: (order: Order): string => + [order.sales_channel.name, order.sales_channel.description].join(";"), + }) + } } export default OrderExportStrategy diff --git a/packages/medusa/src/strategies/batch-jobs/product/export.ts b/packages/medusa/src/strategies/batch-jobs/product/export.ts index 4f6f5b3313..2e52e70c46 100644 --- a/packages/medusa/src/strategies/batch-jobs/product/export.ts +++ b/packages/medusa/src/strategies/batch-jobs/product/export.ts @@ -13,12 +13,15 @@ import { productExportSchemaDescriptors, } from "./index" import { FindProductConfig } from "../../../types/product" +import { FlagRouter } from "../../../utils/flag-router" +import SalesChannelFeatureFlag from "../../../loaders/feature-flags/sales-channels" type InjectedDependencies = { manager: EntityManager batchJobService: BatchJobService productService: ProductService fileService: IFileService + featureFlagRouter: FlagRouter } export default class ProductExportStrategy extends AbstractBatchJobStrategy< @@ -34,6 +37,7 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy< protected readonly batchJobService_: BatchJobService protected readonly productService_: ProductService protected readonly fileService_: IFileService + protected readonly featureFlagRouter_: FlagRouter protected readonly defaultRelations_ = [ ...defaultAdminProductRelations, @@ -49,7 +53,7 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy< protected readonly columnDescriptors: Map< string, ProductExportColumnSchemaDescriptor - > = productExportSchemaDescriptors + > = new Map(productExportSchemaDescriptors) private readonly NEWLINE_ = "\r\n" private readonly DELIMITER_ = ";" @@ -60,18 +64,25 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy< batchJobService, productService, fileService, + featureFlagRouter, }: InjectedDependencies) { super({ manager, batchJobService, productService, fileService, + featureFlagRouter, }) this.manager_ = manager this.batchJobService_ = batchJobService this.productService_ = productService this.fileService_ = fileService + this.featureFlagRouter_ = featureFlagRouter + + if (featureFlagRouter.isFeatureEnabled(SalesChannelFeatureFlag.key)) { + this.defaultRelations_.push("sales_channels") + } } async buildTemplate(): Promise { @@ -138,8 +149,8 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy< let dynamicOptionColumnCount = 0 let dynamicImageColumnCount = 0 - - const pricesData = new Set() + let dynamicSalesChannelsColumnCount = 0 + let pricesData = new Set() while (offset < productCount) { if (!products?.length) { @@ -152,39 +163,20 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy< } as FindProductConfig) } - // Retrieve the highest count of each object to build the dynamic columns later - for (const product of products) { - const optionsCount = product?.options?.length ?? 0 - dynamicOptionColumnCount = Math.max( - dynamicOptionColumnCount, - optionsCount - ) - - const imageCount = product?.images?.length ?? 0 - dynamicImageColumnCount = Math.max( - dynamicImageColumnCount, - imageCount - ) - - for (const variant of product?.variants ?? []) { - if (variant.prices?.length) { - variant.prices.forEach((price) => { - pricesData.add( - JSON.stringify({ - currency_code: price.currency_code, - region: price.region - ? { - currency_code: price.region.currency_code, - name: price.region.name, - id: price.region.id, - } - : null, - }) - ) - }) - } - } - } + const shapeData = this.getProductRelationsDynamicColumnsShape(products) + dynamicImageColumnCount = Math.max( + shapeData.imageColumnCount, + dynamicImageColumnCount + ) + dynamicOptionColumnCount = Math.max( + shapeData.optionColumnCount, + dynamicOptionColumnCount + ) + dynamicSalesChannelsColumnCount = Math.max( + shapeData.salesChannelsColumnCount, + dynamicSalesChannelsColumnCount + ) + pricesData = new Set([...pricesData, ...shapeData.pricesData]) offset += products.length products = [] @@ -197,6 +189,7 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy< shape: { dynamicImageColumnCount, dynamicOptionColumnCount, + dynamicSalesChannelsColumnCount, prices: [...pricesData].map((stringifyData) => JSON.parse(stringifyData) ), @@ -325,11 +318,13 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy< prices = [], dynamicImageColumnCount, dynamicOptionColumnCount, + dynamicSalesChannelsColumnCount, } = batchJob?.context?.shape ?? {} this.appendMoneyAmountDescriptors(prices) this.appendOptionsDescriptors(dynamicOptionColumnCount) this.appendImagesDescriptors(dynamicImageColumnCount) + this.appendSalesChannelsDescriptors(dynamicSalesChannelsColumnCount) return ( [...this.columnDescriptors.keys()].join(this.DELIMITER_) + this.NEWLINE_ @@ -345,6 +340,20 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy< } } + private appendSalesChannelsDescriptors(maxScCount: number): void { + for (let i = 0; i < maxScCount; ++i) { + this.columnDescriptors.set(`Sales channel ${i + 1} Name`, { + accessor: (product: Product) => product?.sales_channels[i]?.name ?? "", + entityName: "product", + }) + this.columnDescriptors.set(`Sales channel ${i + 1} Description`, { + accessor: (product: Product) => + product?.sales_channels[i]?.description ?? "", + entityName: "product", + }) + } + } + private appendOptionsDescriptors(maxOptionsCount: number): void { for (let i = 0; i < maxOptionsCount; ++i) { this.columnDescriptors @@ -449,4 +458,68 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy< }, }) } + + /** + * Return the maximun number of each relation that must appears in the export. + * The number of item of a relation can vary between 0-Infinity and therefore the number of columns + * that will be added to the export correspond to that number + * @param products - The main entity to get the relation shape from + * @private + */ + private getProductRelationsDynamicColumnsShape(products: Product[]): { + optionColumnCount: number + imageColumnCount: number + salesChannelsColumnCount: number + pricesData: Set + } { + let optionColumnCount = 0 + let imageColumnCount = 0 + let salesChannelsColumnCount = 0 + const pricesData = new Set() + + // Retrieve the highest count of each object to build the dynamic columns later + for (const product of products) { + const optionsCount = product?.options?.length ?? 0 + optionColumnCount = Math.max(optionColumnCount, optionsCount) + + const imageCount = product?.images?.length ?? 0 + imageColumnCount = Math.max(imageColumnCount, imageCount) + + if ( + this.featureFlagRouter_.isFeatureEnabled(SalesChannelFeatureFlag.key) + ) { + const salesChannelCount = product?.sales_channels?.length ?? 0 + salesChannelsColumnCount = Math.max( + salesChannelsColumnCount, + salesChannelCount + ) + } + + for (const variant of product?.variants ?? []) { + if (variant.prices?.length) { + variant.prices.forEach((price) => { + pricesData.add( + JSON.stringify({ + currency_code: price.currency_code, + region: price.region + ? { + currency_code: price.region.currency_code, + name: price.region.name, + id: price.region.id, + } + : null, + }) + ) + }) + } + } + } + + return { + optionColumnCount, + imageColumnCount, + salesChannelsColumnCount, + pricesData, + } + } } diff --git a/packages/medusa/src/strategies/batch-jobs/product/index.ts b/packages/medusa/src/strategies/batch-jobs/product/index.ts index 47743c0485..4bad5a93e8 100644 --- a/packages/medusa/src/strategies/batch-jobs/product/index.ts +++ b/packages/medusa/src/strategies/batch-jobs/product/index.ts @@ -14,6 +14,7 @@ export type ProductExportBatchJobContext = { prices: ProductExportPriceData[] dynamicOptionColumnCount: number dynamicImageColumnCount: number + dynamicSalesChannelsColumnCount: number } list_config?: { select?: string[]