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 { IdMap } from "medusa-test-utils"
import {
FulfillmentStatus,
Order,
@@ -39,6 +40,11 @@ export const ordersToExport: DeepPartial<Order>[] = [
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<Order>[] = [
last_name: "Doe",
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 = [
{
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,
@@ -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 { 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()
})
})
@@ -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 { 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()
})
})
@@ -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<any>
fileService: IFileService<never>
orderService: OrderService
batchJobService: BatchJobService
manager: EntityManager
featureFlagRouter: FlagRouter
}
class OrderExportStrategy extends AbstractBatchJobStrategy<OrderExportStrategy> {
@@ -36,6 +39,11 @@ class OrderExportStrategy extends AbstractBatchJobStrategy<OrderExportStrategy>
protected readonly fileService_: IFileService<any>
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<OrderExportStrategy>
batchJobService,
orderService,
manager,
featureFlagRouter,
}: InjectedDependencies) {
// eslint-disable-next-line prefer-rest-params
super(arguments[0])
@@ -69,6 +78,12 @@ class OrderExportStrategy extends AbstractBatchJobStrategy<OrderExportStrategy>
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<OrderExportStrategy>
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<OrderExportStrategy>
}
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<OrderExportStrategy>
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
@@ -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<never>
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<never>
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<string> {
@@ -138,8 +149,8 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy<
let dynamicOptionColumnCount = 0
let dynamicImageColumnCount = 0
const pricesData = new Set<string>()
let dynamicSalesChannelsColumnCount = 0
let pricesData = new Set<string>()
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<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[]
dynamicOptionColumnCount: number
dynamicImageColumnCount: number
dynamicSalesChannelsColumnCount: number
}
list_config?: {
select?: string[]