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
This commit is contained in:
Adrien de Peretti
2022-07-27 17:11:09 +00:00
committed by GitHub
parent df66378535
commit c025074aca
9 changed files with 653 additions and 197 deletions
@@ -1,4 +1,5 @@
import { DeepPartial } from "typeorm" import { DeepPartial } from "typeorm"
import { IdMap } from "medusa-test-utils"
import { import {
FulfillmentStatus, FulfillmentStatus,
Order, Order,
@@ -39,6 +40,11 @@ export const ordersToExport: DeepPartial<Order>[] = [
last_name: "Doe", last_name: "Doe",
email: "John@Doe.com", email: "John@Doe.com",
}, },
sales_channel: {
id: IdMap.getId("sc_1"),
name: "SC 1",
description: "SC 1",
},
}, },
{ {
id: "order_2", id: "order_2",
@@ -70,5 +76,10 @@ export const ordersToExport: DeepPartial<Order>[] = [
last_name: "Doe", last_name: "Doe",
email: "Jane@Doe.com", email: "Jane@Doe.com",
}, },
sales_channel: {
id: IdMap.getId("sc_2"),
name: "SC 2",
description: "SC 2",
},
}, },
] ]
@@ -11,6 +11,9 @@ const variantIds = [
] ]
export const productsToExport = [ export const productsToExport = [
{ {
sales_channels: [
{ id: IdMap.getId("sc_1"), name: "SC 1", description: "SC 1" },
],
collection: { collection: {
created_at: "randomString", created_at: "randomString",
deleted_at: null, deleted_at: null,
@@ -202,6 +205,10 @@ export const productsToExport = [
width: null, 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: { collection: {
created_at: "randomString", created_at: "randomString",
deleted_at: null, deleted_at: null,
@@ -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
",
]
`;
@@ -3,81 +3,9 @@ import { IdMap, MockManager } from "medusa-test-utils"
import { User } from "../../../../models" import { User } from "../../../../models"
import { BatchJobStatus } from "../../../../types/batch-job" import { BatchJobStatus } from "../../../../types/batch-job"
import { ordersToExport } from "../../../__fixtures__/order-export-data" 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 = { const orderServiceMock = {
withTransaction: function (): any { withTransaction: function (): any {
return this return this
@@ -100,11 +28,87 @@ const orderServiceWithoutDataMock = {
} }
describe("Order export strategy", () => { 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({ const orderExportStrategy = new OrderExportStrategy({
batchJobService: batchJobServiceMock as any, batchJobService: batchJobServiceMock as any,
fileService: fileServiceMock as any, fileService: fileServiceMock as any,
orderService: orderServiceMock as any, orderService: orderServiceMock as any,
manager: MockManager, manager: MockManager,
featureFlagRouter: new FlagRouter({}),
}) })
it("Should generate header as template", async () => { it("Should generate header as template", async () => {
@@ -150,6 +154,147 @@ describe("Order export strategy", () => {
fileService: fileServiceMock as any, fileService: fileServiceMock as any,
orderService: orderServiceWithoutDataMock as any, orderService: orderServiceWithoutDataMock as any,
manager: MockManager, manager: MockManager,
featureFlagRouter: new FlagRouter({}),
})
await orderExportStrategy.processJob(fakeJob.id)
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) await orderExportStrategy.processJob(fakeJob.id)
@@ -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
",
]
`;
@@ -6,87 +6,9 @@ import { productsToExport } from "../../../__fixtures__/product-export-data"
import { AdminPostBatchesReq, defaultAdminProductRelations } from "../../../../api" import { AdminPostBatchesReq, defaultAdminProductRelations } from "../../../../api"
import { ProductExportBatchJob } from "../../../batch-jobs/product" import { ProductExportBatchJob } from "../../../batch-jobs/product"
import { Request } from "express" 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 = { const productServiceMock = {
withTransaction: function () { withTransaction: function () {
return this return this
@@ -108,11 +30,92 @@ const productServiceWithNoDataMock = {
const managerMock = MockManager const managerMock = MockManager
describe("Product export strategy", () => { 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({ const productExportStrategy = new ProductExportStrategy({
manager: managerMock, manager: managerMock,
fileService: fileServiceMock as any, fileService: fileServiceMock as any,
batchJobService: batchJobServiceMock as any, batchJobService: batchJobServiceMock as any,
productService: productServiceMock as any, productService: productServiceMock as any,
featureFlagRouter: new FlagRouter({}),
}) })
it('should generate the appropriate template', async () => { 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 Name.*/)
expect(template).toMatch(/.*Option 2 Value.*/) 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 USD.*/)
expect(template).toMatch(/.*Price france \[USD\].*/) expect(template).toMatch(/.*Price france \[USD\].*/)
expect(template).toMatch(/.*Price denmark \[DKK\].*/) expect(template).toMatch(/.*Price denmark \[DKK\].*/)
@@ -242,6 +250,7 @@ describe("Product export strategy", () => {
fileService: fileServiceMock as any, fileService: fileServiceMock as any,
productService: productServiceWithNoDataMock as any, productService: productServiceWithNoDataMock as any,
manager: MockManager, manager: MockManager,
featureFlagRouter: new FlagRouter({}),
}) })
await productExportStrategy.prepareBatchJobForProcessing(fakeJob, {} as Request) await productExportStrategy.prepareBatchJobForProcessing(fakeJob, {} as Request)
@@ -257,6 +266,7 @@ describe("Product export strategy", () => {
fileService: fileServiceMock as any, fileService: fileServiceMock as any,
productService: productServiceMock as any, productService: productServiceMock as any,
manager: MockManager, manager: MockManager,
featureFlagRouter: new FlagRouter({}),
}) })
await productExportStrategy.prepareBatchJobForProcessing(canceledFakeJob, {} as Request) await productExportStrategy.prepareBatchJobForProcessing(canceledFakeJob, {} as Request)
@@ -267,3 +277,165 @@ describe("Product export strategy", () => {
expect((canceledFakeJob.result as any).file_size).not.toBeDefined() 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()
})
})
@@ -5,20 +5,23 @@ import {
OrderExportBatchJobContext, OrderExportBatchJobContext,
orderExportPropertiesDescriptors, orderExportPropertiesDescriptors,
} from "." } from "."
import { AdminPostBatchesReq } from "../../../api/routes/admin/batch/create-batch-job" import { AdminPostBatchesReq } from "../../../api"
import { IFileService } from "../../../interfaces" import { IFileService } from "../../../interfaces"
import { AbstractBatchJobStrategy } from "../../../interfaces/batch-job-strategy" import { AbstractBatchJobStrategy } from "../../../interfaces"
import { Order } from "../../../models" import { Order } from "../../../models"
import { OrderService } from "../../../services" import { OrderService } from "../../../services"
import BatchJobService from "../../../services/batch-job" import BatchJobService from "../../../services/batch-job"
import { BatchJobStatus } from "../../../types/batch-job" import { BatchJobStatus } from "../../../types/batch-job"
import { prepareListQuery } from "../../../utils/get-query-config" import { prepareListQuery } from "../../../utils/get-query-config"
import { FlagRouter } from "../../../utils/flag-router"
import SalesChannelFeatureFlag from "../../../loaders/feature-flags/sales-channels"
type InjectedDependencies = { type InjectedDependencies = {
fileService: IFileService<any> fileService: IFileService<never>
orderService: OrderService orderService: OrderService
batchJobService: BatchJobService batchJobService: BatchJobService
manager: EntityManager manager: EntityManager
featureFlagRouter: FlagRouter
} }
class OrderExportStrategy extends AbstractBatchJobStrategy<OrderExportStrategy> { class OrderExportStrategy extends AbstractBatchJobStrategy<OrderExportStrategy> {
@@ -36,6 +39,11 @@ class OrderExportStrategy extends AbstractBatchJobStrategy<OrderExportStrategy>
protected readonly fileService_: IFileService<any> protected readonly fileService_: IFileService<any>
protected readonly batchJobService_: BatchJobService protected readonly batchJobService_: BatchJobService
protected readonly orderService_: OrderService protected readonly orderService_: OrderService
protected readonly featureFlagRouter_: FlagRouter
protected readonly orderExportPropertiesDescriptors = [
...orderExportPropertiesDescriptors,
]
protected readonly defaultRelations_ = ["customer", "shipping_address"] protected readonly defaultRelations_ = ["customer", "shipping_address"]
protected readonly defaultFields_ = [ protected readonly defaultFields_ = [
@@ -61,6 +69,7 @@ class OrderExportStrategy extends AbstractBatchJobStrategy<OrderExportStrategy>
batchJobService, batchJobService,
orderService, orderService,
manager, manager,
featureFlagRouter,
}: InjectedDependencies) { }: InjectedDependencies) {
// eslint-disable-next-line prefer-rest-params // eslint-disable-next-line prefer-rest-params
super(arguments[0]) super(arguments[0])
@@ -69,6 +78,12 @@ class OrderExportStrategy extends AbstractBatchJobStrategy<OrderExportStrategy>
this.fileService_ = fileService this.fileService_ = fileService
this.batchJobService_ = batchJobService this.batchJobService_ = batchJobService
this.orderService_ = orderService this.orderService_ = orderService
this.featureFlagRouter_ = featureFlagRouter
if (featureFlagRouter.isFeatureEnabled(SalesChannelFeatureFlag.key)) {
this.defaultRelations_.push("sales_channel")
this.addSalesChannelColumns()
}
} }
async prepareBatchJobForProcessing( async prepareBatchJobForProcessing(
@@ -253,13 +268,12 @@ class OrderExportStrategy extends AbstractBatchJobStrategy<OrderExportStrategy>
await promise await promise
}, },
"REPEATABLE READ", "REPEATABLE READ",
async (err: Error) => { async (err: Error) =>
this.handleProcessingError(batchJobId, err, { this.handleProcessingError(batchJobId, err, {
offset, offset,
count: orderCount, count: orderCount,
progress: offset / orderCount, progress: offset / orderCount,
}) })
}
) )
} }
@@ -270,7 +284,7 @@ class OrderExportStrategy extends AbstractBatchJobStrategy<OrderExportStrategy>
} }
private buildHeader( private buildHeader(
lineDescriptor: OrderDescriptor[] = orderExportPropertiesDescriptors lineDescriptor: OrderDescriptor[] = this.orderExportPropertiesDescriptors
): string { ): string {
return ( return (
[...lineDescriptor.map(({ title }) => title)].join(this.DELIMITER) + [...lineDescriptor.map(({ title }) => title)].join(this.DELIMITER) +
@@ -293,11 +307,20 @@ class OrderExportStrategy extends AbstractBatchJobStrategy<OrderExportStrategy>
fields: string[], fields: string[],
relations: string[] relations: string[]
): OrderDescriptor[] { ): OrderDescriptor[] {
return orderExportPropertiesDescriptors.filter( return this.orderExportPropertiesDescriptors.filter(
({ fieldName }) => ({ fieldName }) =>
fields.indexOf(fieldName) !== -1 || relations.indexOf(fieldName) !== -1 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 export default OrderExportStrategy
@@ -13,12 +13,15 @@ import {
productExportSchemaDescriptors, productExportSchemaDescriptors,
} from "./index" } from "./index"
import { FindProductConfig } from "../../../types/product" import { FindProductConfig } from "../../../types/product"
import { FlagRouter } from "../../../utils/flag-router"
import SalesChannelFeatureFlag from "../../../loaders/feature-flags/sales-channels"
type InjectedDependencies = { type InjectedDependencies = {
manager: EntityManager manager: EntityManager
batchJobService: BatchJobService batchJobService: BatchJobService
productService: ProductService productService: ProductService
fileService: IFileService<never> fileService: IFileService<never>
featureFlagRouter: FlagRouter
} }
export default class ProductExportStrategy extends AbstractBatchJobStrategy< export default class ProductExportStrategy extends AbstractBatchJobStrategy<
@@ -34,6 +37,7 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy<
protected readonly batchJobService_: BatchJobService protected readonly batchJobService_: BatchJobService
protected readonly productService_: ProductService protected readonly productService_: ProductService
protected readonly fileService_: IFileService<never> protected readonly fileService_: IFileService<never>
protected readonly featureFlagRouter_: FlagRouter
protected readonly defaultRelations_ = [ protected readonly defaultRelations_ = [
...defaultAdminProductRelations, ...defaultAdminProductRelations,
@@ -49,7 +53,7 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy<
protected readonly columnDescriptors: Map< protected readonly columnDescriptors: Map<
string, string,
ProductExportColumnSchemaDescriptor ProductExportColumnSchemaDescriptor
> = productExportSchemaDescriptors > = new Map(productExportSchemaDescriptors)
private readonly NEWLINE_ = "\r\n" private readonly NEWLINE_ = "\r\n"
private readonly DELIMITER_ = ";" private readonly DELIMITER_ = ";"
@@ -60,18 +64,25 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy<
batchJobService, batchJobService,
productService, productService,
fileService, fileService,
featureFlagRouter,
}: InjectedDependencies) { }: InjectedDependencies) {
super({ super({
manager, manager,
batchJobService, batchJobService,
productService, productService,
fileService, fileService,
featureFlagRouter,
}) })
this.manager_ = manager this.manager_ = manager
this.batchJobService_ = batchJobService this.batchJobService_ = batchJobService
this.productService_ = productService this.productService_ = productService
this.fileService_ = fileService this.fileService_ = fileService
this.featureFlagRouter_ = featureFlagRouter
if (featureFlagRouter.isFeatureEnabled(SalesChannelFeatureFlag.key)) {
this.defaultRelations_.push("sales_channels")
}
} }
async buildTemplate(): Promise<string> { async buildTemplate(): Promise<string> {
@@ -138,8 +149,8 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy<
let dynamicOptionColumnCount = 0 let dynamicOptionColumnCount = 0
let dynamicImageColumnCount = 0 let dynamicImageColumnCount = 0
let dynamicSalesChannelsColumnCount = 0
const pricesData = new Set<string>() let pricesData = new Set<string>()
while (offset < productCount) { while (offset < productCount) {
if (!products?.length) { if (!products?.length) {
@@ -152,39 +163,20 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy<
} as FindProductConfig) } as FindProductConfig)
} }
// Retrieve the highest count of each object to build the dynamic columns later const shapeData = this.getProductRelationsDynamicColumnsShape(products)
for (const product of products) { dynamicImageColumnCount = Math.max(
const optionsCount = product?.options?.length ?? 0 shapeData.imageColumnCount,
dynamicOptionColumnCount = Math.max( dynamicImageColumnCount
dynamicOptionColumnCount, )
optionsCount dynamicOptionColumnCount = Math.max(
) shapeData.optionColumnCount,
dynamicOptionColumnCount
const imageCount = product?.images?.length ?? 0 )
dynamicImageColumnCount = Math.max( dynamicSalesChannelsColumnCount = Math.max(
dynamicImageColumnCount, shapeData.salesChannelsColumnCount,
imageCount dynamicSalesChannelsColumnCount
) )
pricesData = new Set([...pricesData, ...shapeData.pricesData])
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,
})
)
})
}
}
}
offset += products.length offset += products.length
products = [] products = []
@@ -197,6 +189,7 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy<
shape: { shape: {
dynamicImageColumnCount, dynamicImageColumnCount,
dynamicOptionColumnCount, dynamicOptionColumnCount,
dynamicSalesChannelsColumnCount,
prices: [...pricesData].map((stringifyData) => prices: [...pricesData].map((stringifyData) =>
JSON.parse(stringifyData) JSON.parse(stringifyData)
), ),
@@ -325,11 +318,13 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy<
prices = [], prices = [],
dynamicImageColumnCount, dynamicImageColumnCount,
dynamicOptionColumnCount, dynamicOptionColumnCount,
dynamicSalesChannelsColumnCount,
} = batchJob?.context?.shape ?? {} } = batchJob?.context?.shape ?? {}
this.appendMoneyAmountDescriptors(prices) this.appendMoneyAmountDescriptors(prices)
this.appendOptionsDescriptors(dynamicOptionColumnCount) this.appendOptionsDescriptors(dynamicOptionColumnCount)
this.appendImagesDescriptors(dynamicImageColumnCount) this.appendImagesDescriptors(dynamicImageColumnCount)
this.appendSalesChannelsDescriptors(dynamicSalesChannelsColumnCount)
return ( return (
[...this.columnDescriptors.keys()].join(this.DELIMITER_) + this.NEWLINE_ [...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 { private appendOptionsDescriptors(maxOptionsCount: number): void {
for (let i = 0; i < maxOptionsCount; ++i) { for (let i = 0; i < maxOptionsCount; ++i) {
this.columnDescriptors 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<string>
} {
let optionColumnCount = 0
let imageColumnCount = 0
let salesChannelsColumnCount = 0
const pricesData = new Set<string>()
// 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,
}
}
} }
@@ -14,6 +14,7 @@ export type ProductExportBatchJobContext = {
prices: ProductExportPriceData[] prices: ProductExportPriceData[]
dynamicOptionColumnCount: number dynamicOptionColumnCount: number
dynamicImageColumnCount: number dynamicImageColumnCount: number
dynamicSalesChannelsColumnCount: number
} }
list_config?: { list_config?: {
select?: string[] select?: string[]