Просмотр исходного кода

feat(server): Initial implementation of product data import

Michael Bromley 7 лет назад
Родитель
Сommit
46ae227ff3
34 измененных файлов с 1622 добавлено и 20 удалено
  1. 0 0
      schema.json
  2. 3 0
      server/dev-config.ts
  3. 272 0
      server/e2e/__snapshots__/import.e2e-spec.ts.snap
  4. 4 0
      server/e2e/config/test-config.ts
  5. BIN
      server/e2e/fixtures/pps1.jpg
  6. BIN
      server/e2e/fixtures/pps2.jpg
  7. 11 0
      server/e2e/fixtures/product-import.csv
  8. 75 0
      server/e2e/import.e2e-spec.ts
  9. 45 0
      server/mock-data/create-upload-post-data.spec.ts
  10. 76 0
      server/mock-data/create-upload-post-data.ts
  11. 43 17
      server/mock-data/simple-graphql-client.ts
  12. 6 1
      server/package.json
  13. 4 0
      server/src/api/api.module.ts
  14. 47 0
      server/src/api/resolvers/import.resolver.ts
  15. 8 0
      server/src/api/types/import.api.graphql
  16. 0 1
      server/src/app.module.ts
  17. 1 0
      server/src/config/config.service.mock.ts
  18. 5 0
      server/src/config/config.service.ts
  19. 3 0
      server/src/config/default-config.ts
  20. 11 0
      server/src/config/vendure-config.ts
  21. 14 0
      server/src/data-import/data-import.module.ts
  22. 49 0
      server/src/data-import/import-cli.ts
  23. 425 0
      server/src/data-import/providers/import-parser/__snapshots__/import-parser.spec.ts.snap
  24. 79 0
      server/src/data-import/providers/import-parser/import-parser.spec.ts
  25. 204 0
      server/src/data-import/providers/import-parser/import-parser.ts
  26. 5 0
      server/src/data-import/providers/import-parser/test-fixtures/invalid-columns.csv
  27. 5 0
      server/src/data-import/providers/import-parser/test-fixtures/invalid-option-values.csv
  28. 11 0
      server/src/data-import/providers/import-parser/test-fixtures/multiple-products-multiple-variants.csv
  29. 4 0
      server/src/data-import/providers/import-parser/test-fixtures/single-product-multiple-variants.csv
  30. 2 0
      server/src/data-import/providers/import-parser/test-fixtures/single-product-single-variant.csv
  31. 120 0
      server/src/data-import/providers/importer/importer.ts
  32. 27 1
      server/src/service/services/asset.service.ts
  33. 29 0
      server/yarn.lock
  34. 34 0
      shared/generated-types.ts

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
schema.json


+ 3 - 0
server/dev-config.ts

@@ -43,6 +43,9 @@ export const devConfig: VendureConfig = {
             outputPath: path.join(__dirname, 'test-emails'),
         },
     },
+    importExportOptions: {
+        importAssetsDir: path.join(__dirname, 'import-assets'),
+    },
     plugins: [
         new DefaultAssetServerPlugin({
             route: 'assets',

+ 272 - 0
server/e2e/__snapshots__/import.e2e-spec.ts.snap

@@ -0,0 +1,272 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Import resolver imports products 1`] = `
+Array [
+  Object {
+    "assets": Array [
+      Object {
+        "id": "T_6",
+        "name": "pps1.jpg",
+      },
+      Object {
+        "id": "T_7",
+        "name": "pps2.jpg",
+      },
+    ],
+    "description": "A great device for stretching paper.",
+    "featuredAsset": Object {
+      "id": "T_6",
+      "name": "pps1.jpg",
+    },
+    "id": "T_1",
+    "name": "Perfect Paper Stretcher",
+    "optionGroups": Array [
+      Object {
+        "code": "perfect-paper-stretcher-size",
+        "id": "T_2",
+        "name": "size",
+      },
+    ],
+    "slug": "perfect-paper-stretcher",
+    "variants": Array [
+      Object {
+        "id": "T_1",
+        "name": "Perfect Paper Stretcher Half Imperial",
+        "options": Array [
+          Object {
+            "code": "half-imperial",
+            "id": "T_3",
+          },
+        ],
+        "price": 4530,
+        "sku": "PPS12",
+        "taxCategory": Object {
+          "id": "T_1",
+          "name": "Standard Tax",
+        },
+      },
+      Object {
+        "id": "T_2",
+        "name": "Perfect Paper Stretcher Quarter Imperial",
+        "options": Array [
+          Object {
+            "code": "quarter-imperial",
+            "id": "T_4",
+          },
+        ],
+        "price": 3250,
+        "sku": "PPS14",
+        "taxCategory": Object {
+          "id": "T_1",
+          "name": "Standard Tax",
+        },
+      },
+      Object {
+        "id": "T_3",
+        "name": "Perfect Paper Stretcher Full Imperial",
+        "options": Array [
+          Object {
+            "code": "full-imperial",
+            "id": "T_5",
+          },
+        ],
+        "price": 5950,
+        "sku": "PPSF",
+        "taxCategory": Object {
+          "id": "T_1",
+          "name": "Standard Tax",
+        },
+      },
+    ],
+  },
+  Object {
+    "assets": Array [],
+    "description": "Mabef description",
+    "featuredAsset": null,
+    "id": "T_2",
+    "name": "Mabef M/02 Studio Easel",
+    "optionGroups": Array [],
+    "slug": "",
+    "variants": Array [
+      Object {
+        "id": "T_4",
+        "name": "Mabef M/02 Studio Easel",
+        "options": Array [],
+        "price": 91070,
+        "sku": "M02",
+        "taxCategory": Object {
+          "id": "T_1",
+          "name": "Standard Tax",
+        },
+      },
+    ],
+  },
+  Object {
+    "assets": Array [],
+    "description": "Really mega pencils",
+    "featuredAsset": null,
+    "id": "T_3",
+    "name": "Giotto Mega Pencils",
+    "optionGroups": Array [
+      Object {
+        "code": "giotto-mega-pencils-box-size",
+        "id": "T_3",
+        "name": "box size",
+      },
+    ],
+    "slug": "",
+    "variants": Array [
+      Object {
+        "id": "T_5",
+        "name": "Giotto Mega Pencils Box of 8",
+        "options": Array [
+          Object {
+            "code": "box-of-8",
+            "id": "T_6",
+          },
+        ],
+        "price": 416,
+        "sku": "225400",
+        "taxCategory": Object {
+          "id": "T_1",
+          "name": "Standard Tax",
+        },
+      },
+      Object {
+        "id": "T_6",
+        "name": "Giotto Mega Pencils Box of 12",
+        "options": Array [
+          Object {
+            "code": "box-of-12",
+            "id": "T_7",
+          },
+        ],
+        "price": 624,
+        "sku": "225600",
+        "taxCategory": Object {
+          "id": "T_1",
+          "name": "Standard Tax",
+        },
+      },
+    ],
+  },
+  Object {
+    "assets": Array [],
+    "description": "Keeps the paint off the clothes",
+    "featuredAsset": null,
+    "id": "T_4",
+    "name": "Artists Smock",
+    "optionGroups": Array [
+      Object {
+        "code": "artists-smock-size",
+        "id": "T_4",
+        "name": "size",
+      },
+      Object {
+        "code": "artists-smock-colour",
+        "id": "T_5",
+        "name": "colour",
+      },
+    ],
+    "slug": "",
+    "variants": Array [
+      Object {
+        "id": "T_7",
+        "name": "Artists Smock small beige",
+        "options": Array [
+          Object {
+            "code": "small",
+            "id": "T_1",
+          },
+          Object {
+            "code": "small",
+            "id": "T_8",
+          },
+          Object {
+            "code": "beige",
+            "id": "T_10",
+          },
+        ],
+        "price": 1199,
+        "sku": "10112",
+        "taxCategory": Object {
+          "id": "T_2",
+          "name": "Reduced Tax",
+        },
+      },
+      Object {
+        "id": "T_8",
+        "name": "Artists Smock large beige",
+        "options": Array [
+          Object {
+            "code": "large",
+            "id": "T_2",
+          },
+          Object {
+            "code": "large",
+            "id": "T_9",
+          },
+          Object {
+            "code": "beige",
+            "id": "T_10",
+          },
+        ],
+        "price": 1199,
+        "sku": "10113",
+        "taxCategory": Object {
+          "id": "T_2",
+          "name": "Reduced Tax",
+        },
+      },
+      Object {
+        "id": "T_9",
+        "name": "Artists Smock small navy",
+        "options": Array [
+          Object {
+            "code": "small",
+            "id": "T_1",
+          },
+          Object {
+            "code": "small",
+            "id": "T_8",
+          },
+          Object {
+            "code": "navy",
+            "id": "T_11",
+          },
+        ],
+        "price": 1199,
+        "sku": "10114",
+        "taxCategory": Object {
+          "id": "T_2",
+          "name": "Reduced Tax",
+        },
+      },
+      Object {
+        "id": "T_10",
+        "name": "Artists Smock large navy",
+        "options": Array [
+          Object {
+            "code": "large",
+            "id": "T_2",
+          },
+          Object {
+            "code": "large",
+            "id": "T_9",
+          },
+          Object {
+            "code": "navy",
+            "id": "T_11",
+          },
+        ],
+        "price": 1199,
+        "sku": "10115",
+        "taxCategory": Object {
+          "id": "T_2",
+          "name": "Reduced Tax",
+        },
+      },
+    ],
+  },
+]
+`;

+ 4 - 0
server/e2e/config/test-config.ts

@@ -1,3 +1,4 @@
+import * as path from 'path';
 import { API_PATH } from 'shared/shared-constants';
 
 import { DefaultAssetNamingStrategy } from '../../src/config/asset-naming-strategy/default-asset-naming-strategy';
@@ -45,6 +46,9 @@ export const testConfig: VendureConfig = {
             type: 'none',
         },
     },
+    importExportOptions: {
+        importAssetsDir: path.join(__dirname, '..', 'fixtures'),
+    },
     assetOptions: {
         assetNamingStrategy: new DefaultAssetNamingStrategy(),
         assetStorageStrategy: new TestingAssetStorageStrategy(),

BIN
server/e2e/fixtures/pps1.jpg


BIN
server/e2e/fixtures/pps2.jpg


+ 11 - 0
server/e2e/fixtures/product-import.csv

@@ -0,0 +1,11 @@
+name                    , slug                    , description                          , assets               , optionGroups   , optionValues     , sku    , price , taxCategory , variantAssets
+Perfect Paper Stretcher , perfect-paper-stretcher , A great device for stretching paper. , "pps1.jpg, pps2.jpg" , size           , Half Imperial    , PPS12  , 45.3  , standard    ,
+                        ,                         ,                                      ,                      ,                , Quarter Imperial , PPS14  , 32.5  , standard    ,
+                        ,                         ,                                      ,                      ,                , Full Imperial    , PPSF   , 59.5  , standard    ,
+Mabef M/02 Studio Easel ,                         , Mabef description                    ,                      ,                ,                  , M02    , 910.7 , standard    ,
+Giotto Mega Pencils     ,                         , Really mega pencils                  ,                      , box size       , Box of 8         , 225400 , 4.16  , standard    ,
+                        ,                         ,                                      ,                      ,                , Box of 12        , 225600 , 6.24  , standard    ,
+Artists Smock           ,                         , Keeps the paint off the clothes      ,                      , "size, colour" , "small, beige"   , 10112  , 11.99 , reduced     ,
+                        ,                         ,                                      ,                      ,                , "large, beige"   , 10113  , 11.99 , reduced     ,
+                        ,                         ,                                      ,                      ,                , "small, navy"    , 10114  , 11.99 , reduced     ,
+                        ,                         ,                                      ,                      ,                , "large, navy"    , 10115  , 11.99 , reduced     ,

+ 75 - 0
server/e2e/import.e2e-spec.ts

@@ -0,0 +1,75 @@
+import gql from 'graphql-tag';
+import * as path from 'path';
+
+import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import { TestClient } from './test-client';
+import { TestServer } from './test-server';
+
+describe('Import resolver', () => {
+    const client = new TestClient();
+    const server = new TestServer();
+
+    beforeAll(async () => {
+        const token = await server.init({
+            productCount: 0,
+            customerCount: 0,
+        });
+        await client.init();
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it('imports products', async () => {
+        const csvFile = path.join(__dirname, 'fixtures', 'product-import.csv');
+        const result = await client.importProducts(csvFile);
+
+        expect(result.importProducts.errors).toEqual([]);
+        expect(result.importProducts.importedCount).toBe(4);
+
+        const productResult = await client.query(gql`
+            query {
+                products {
+                    totalItems
+                    items {
+                        id
+                        name
+                        slug
+                        description
+                        featuredAsset {
+                            id
+                            name
+                        }
+                        assets {
+                            id
+                            name
+                        }
+                        optionGroups {
+                            id
+                            code
+                            name
+                        }
+                        variants {
+                            id
+                            name
+                            sku
+                            price
+                            taxCategory {
+                                id
+                                name
+                            }
+                            options {
+                                id
+                                code
+                            }
+                        }
+                    }
+                }
+            }
+        `);
+
+        expect(productResult.products.totalItems).toBe(4);
+        expect(productResult.products.items).toMatchSnapshot();
+    });
+});

+ 45 - 0
server/mock-data/create-upload-post-data.spec.ts

@@ -0,0 +1,45 @@
+import gql from 'graphql-tag';
+
+import { CREATE_ASSETS } from '../../admin-ui/src/app/data/definitions/product-definitions';
+
+import { createUploadPostData } from './create-upload-post-data';
+
+describe('createUploadPostData()', () => {
+    it('creates correct output for createAssets mutation', () => {
+        const result = createUploadPostData(CREATE_ASSETS, ['a.jpg', 'b.jpg'], filePaths => ({
+            input: filePaths.map(() => ({ file: null })),
+        }));
+
+        expect(result.operations.operationName).toBe('CreateAssets');
+        expect(result.operations.variables).toEqual({
+            input: [{ file: null }, { file: null }],
+        });
+        expect(result.map).toEqual({
+            0: 'variables.input.0.file',
+            1: 'variables.input.1.file',
+        });
+        expect(result.filePaths).toEqual([{ name: '0', file: 'a.jpg' }, { name: '1', file: 'b.jpg' }]);
+    });
+
+    it('creates correct output for importProducts mutation', () => {
+        const result = createUploadPostData(
+            gql`
+                mutation ImportProducts($input: Upload!) {
+                    importProducts(csvFile: $input) {
+                        errors
+                        importedCount
+                    }
+                }
+            `,
+            'data.csv',
+            () => ({ csvFile: null }),
+        );
+
+        expect(result.operations.operationName).toBe('ImportProducts');
+        expect(result.operations.variables).toEqual({ csvFile: null });
+        expect(result.map).toEqual({
+            0: 'variables.csvFile',
+        });
+        expect(result.filePaths).toEqual([{ name: '0', file: 'data.csv' }]);
+    });
+});

+ 76 - 0
server/mock-data/create-upload-post-data.ts

@@ -0,0 +1,76 @@
+import { DocumentNode, Kind, OperationDefinitionNode, print } from 'graphql';
+
+export interface FilePlaceholder {
+    file: null;
+}
+export interface UploadPostData<V = any> {
+    operations: {
+        operationName: string;
+        variables: V;
+        query: string;
+    };
+
+    map: {
+        [index: number]: string;
+    };
+    filePaths: Array<{
+        name: string;
+        file: string;
+    }>;
+}
+
+/**
+ * Creates a data structure which can be used to mae a curl request to upload files to a mutation using
+ * the Upload type.
+ */
+export function createUploadPostData<P extends string[] | string, V>(
+    mutation: DocumentNode,
+    filePaths: P,
+    mapVariables: (filePaths: P) => V,
+): UploadPostData<V> {
+    const operationDef = mutation.definitions.find(
+        d => d.kind === Kind.OPERATION_DEFINITION,
+    ) as OperationDefinitionNode;
+
+    const filePathsArray = (Array.isArray(filePaths) ? filePaths : [filePaths]) as string[];
+    const variables = mapVariables(filePaths);
+    const postData: UploadPostData = {
+        operations: {
+            operationName: operationDef.name ? operationDef.name.value : 'AnonymousMutation',
+            variables,
+            query: print(mutation),
+        },
+        map: filePathsArray.reduce(
+            (output, filePath, i) => {
+                return { ...output, [i.toString()]: objectPath(variables, i).join('.') };
+            },
+            {} as { [index: number]: string },
+        ),
+        filePaths: filePathsArray.map((filePath, i) => ({
+            name: i.toString(),
+            file: filePath,
+        })),
+    };
+    return postData;
+}
+
+function objectPath(variables: any, i: number): Array<string | number> {
+    const path: Array<string | number> = ['variables'];
+    let current = variables;
+    while (current !== null) {
+        const props = Object.getOwnPropertyNames(current);
+        if (props) {
+            const firstProp = props[0];
+            const val = current[firstProp];
+            if (Array.isArray(val)) {
+                path.push(firstProp);
+                path.push(i);
+                current = val[0];
+            } else {
+                path.push(firstProp);
+                current = val;
+            }
+        }
+    }
+    return path;
+}

+ 43 - 17
server/mock-data/simple-graphql-client.ts

@@ -3,12 +3,14 @@ import { GraphQLClient } from 'graphql-request';
 import gql from 'graphql-tag';
 import { print } from 'graphql/language/printer';
 import { Curl } from 'node-libcurl';
-import { CreateAssets } from 'shared/generated-types';
+import { CreateAssets, ImportInfo } from 'shared/generated-types';
 import { SUPER_ADMIN_USER_IDENTIFIER, SUPER_ADMIN_USER_PASSWORD } from 'shared/shared-constants';
 
 import { CREATE_ASSETS } from '../../admin-ui/src/app/data/definitions/product-definitions';
 import { getConfig } from '../src/config/vendure-config';
 
+import { createUploadPostData } from './create-upload-post-data';
+
 // tslint:disable:no-console
 /**
  * A minimalistic GraphQL client for populating and querying test data.
@@ -56,44 +58,68 @@ export class SimpleGraphQLClient {
         return result.status;
     }
 
+    uploadAssets(filePaths: string[]): Promise<CreateAssets.Mutation> {
+        return this.fileUploadMutation({
+            mutation: CREATE_ASSETS,
+            filePaths,
+            mapVariables: fp => ({
+                input: fp.map(() => ({ file: null })),
+            }),
+        });
+    }
+
+    importProducts(csvFilePath: string): Promise<{ importProducts: ImportInfo }> {
+        return this.fileUploadMutation({
+            mutation: gql`
+                mutation ImportProducts($csvFile: Upload!) {
+                    importProducts(csvFile: $csvFile) {
+                        importedCount
+                        errors
+                    }
+                }
+            `,
+            filePaths: [csvFilePath],
+            mapVariables: () => ({ csvFile: null }),
+        });
+    }
+
     /**
-     * Uses curl to post a multipart/form-data request to create new assets. Due to differences between the Node and browser
+     * Uses curl to post a multipart/form-data request to the server. Due to differences between the Node and browser
      * environments, we cannot just use an existing library like apollo-upload-client.
      *
      * Upload spec: https://github.com/jaydenseric/graphql-multipart-request-spec
      * Discussion of issue: https://github.com/jaydenseric/apollo-upload-client/issues/32
      */
-    uploadAssets(filePaths: string[]): Promise<CreateAssets.Mutation> {
+    private fileUploadMutation(options: {
+        mutation: DocumentNode;
+        filePaths: string[];
+        mapVariables: (filePaths: string[]) => any;
+    }): Promise<any> {
+        const { mutation, filePaths, mapVariables } = options;
         return new Promise((resolve, reject) => {
             const curl = new Curl();
 
-            const postData: any[] = [
+            const postData = createUploadPostData(mutation, filePaths, mapVariables);
+            const processedPostData = [
                 {
                     name: 'operations',
-                    contents: JSON.stringify({
-                        operationName: 'CreateAssets',
-                        variables: {
-                            input: filePaths.map(() => ({ file: null })),
-                        },
-                        query: print(CREATE_ASSETS),
-                    }),
+                    contents: JSON.stringify(postData.operations),
                 },
                 {
                     name: 'map',
                     contents:
                         '{' +
-                        filePaths.map((filePath, i) => `"${i}":["variables.input.${i}.file"]`).join(',') +
+                        Object.entries(postData.map)
+                            .map(([i, path]) => `"${i}":["${path}"]`)
+                            .join(',') +
                         '}',
                 },
-                ...filePaths.map((filePath, i) => ({
-                    name: i.toString(),
-                    file: filePath,
-                })),
+                ...postData.filePaths,
             ];
             curl.setOpt(Curl.option.URL, this.apiUrl);
             curl.setOpt(Curl.option.VERBOSE, false);
             curl.setOpt(Curl.option.TIMEOUT_MS, 30000);
-            curl.setOpt(Curl.option.HTTPPOST, postData);
+            curl.setOpt(Curl.option.HTTPPOST, processedPostData);
             curl.setOpt(Curl.option.HTTPHEADER, [
                 `Authorization: Bearer ${this.authToken}`,
                 `${getConfig().channelTokenKey}: ${this.channelToken}`,

+ 6 - 1
server/package.json

@@ -30,6 +30,7 @@
     "bcrypt": "^3.0.2",
     "body-parser": "^1.18.3",
     "cookie-session": "^2.0.0-beta.3",
+    "csv-parse": "^4.0.1",
     "dateformat": "^3.0.3",
     "express": "^4.16.4",
     "fs-extra": "^7.0.1",
@@ -44,6 +45,7 @@
     "i18next-express-middleware": "^1.5.0",
     "i18next-icu": "^0.6.0",
     "i18next-node-fs-backend": "^2.1.0",
+    "mime-types": "^2.1.21",
     "mjml": "^4.2.0",
     "ms": "^2.1.1",
     "mysql": "^2.16.0",
@@ -58,6 +60,7 @@
   "devDependencies": {
     "@types/bcrypt": "^3.0.0",
     "@types/cookie-session": "^2.0.36",
+    "@types/csv-parse": "^1.1.11",
     "@types/express": "^4.0.39",
     "@types/faker": "^4.1.4",
     "@types/fs-extra": "^5.0.4",
@@ -65,6 +68,7 @@
     "@types/i18next": "^11.9.3",
     "@types/i18next-express-middleware": "^0.0.33",
     "@types/jest": "^23.3.9",
+    "@types/mime-types": "^2.1.0",
     "@types/mjml": "^4.0.1",
     "@types/nanoid": "^1.2.0",
     "@types/node": "^10.12.9",
@@ -93,7 +97,8 @@
     },
     "roots": [
       "src",
-      "../shared"
+      "../shared",
+      "mock-data"
     ],
     "transform": {
       "^.+\\.(t|j)s$": "ts-jest"

+ 4 - 0
server/src/api/api.module.ts

@@ -3,6 +3,7 @@ import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
 import { GraphQLModule } from '@nestjs/graphql';
 
 import { ConfigModule } from '../config/config.module';
+import { DataImportModule } from '../data-import/data-import.module';
 import { I18nModule } from '../i18n/i18n.module';
 import { ServiceModule } from '../service/service.module';
 
@@ -21,6 +22,7 @@ import { CountryResolver } from './resolvers/country.resolver';
 import { CustomerGroupResolver } from './resolvers/customer-group.resolver';
 import { CustomerResolver } from './resolvers/customer.resolver';
 import { FacetResolver } from './resolvers/facet.resolver';
+import { ImportResolver } from './resolvers/import.resolver';
 import { OrderResolver } from './resolvers/order.resolver';
 import { PaymentMethodResolver } from './resolvers/payment-method.resolver';
 import { ProductCategoryResolver } from './resolvers/product-category.resolver';
@@ -42,6 +44,7 @@ const exportedProviders = [
     ConfigResolver,
     CountryResolver,
     FacetResolver,
+    ImportResolver,
     CustomerResolver,
     CustomerGroupResolver,
     OrderResolver,
@@ -64,6 +67,7 @@ const exportedProviders = [
 @Module({
     imports: [
         ServiceModule,
+        DataImportModule,
         GraphQLModule.forRootAsync({
             useClass: GraphqlConfigService,
             imports: [ConfigModule, I18nModule],

+ 47 - 0
server/src/api/resolvers/import.resolver.ts

@@ -0,0 +1,47 @@
+import { Args, Mutation, Resolver } from '@nestjs/graphql';
+import { ImportInfo, ImportProductsMutationArgs, Permission } from 'shared/generated-types';
+
+import {
+    ImportParser,
+    ParsedProductWithVariants,
+} from '../../data-import/providers/import-parser/import-parser';
+import { Importer } from '../../data-import/providers/importer/importer';
+import { RequestContext } from '../common/request-context';
+import { Allow } from '../decorators/allow.decorator';
+import { Ctx } from '../decorators/request-context.decorator';
+
+@Resolver('Import')
+export class ImportResolver {
+    constructor(private importParser: ImportParser, private importer: Importer) {}
+
+    @Mutation()
+    @Allow(Permission.SuperAdmin)
+    async importProducts(
+        @Ctx() ctx: RequestContext,
+        @Args() args: ImportProductsMutationArgs,
+    ): Promise<ImportInfo> {
+        const { stream, filename, mimetype, encoding } = await args.csvFile;
+        let parsed: ParsedProductWithVariants[];
+        try {
+            parsed = await this.importParser.parseProducts(stream);
+        } catch (err) {
+            return {
+                errors: [err.message],
+                importedCount: 0,
+            };
+        }
+
+        if (parsed) {
+            const result = await this.importer.importProducts(ctx, parsed);
+            return {
+                errors: [],
+                importedCount: parsed.length,
+            };
+        } else {
+            return {
+                errors: ['nothing to parse!'],
+                importedCount: 0,
+            };
+        }
+    }
+}

+ 8 - 0
server/src/api/types/import.api.graphql

@@ -0,0 +1,8 @@
+type Mutation {
+    importProducts(csvFile: Upload!): ImportInfo
+}
+
+type ImportInfo {
+    errors: [String!]
+    importedCount: Int!
+}

+ 0 - 1
server/src/app.module.ts

@@ -2,7 +2,6 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
 import cookieSession = require('cookie-session');
 import { RequestHandler } from 'express';
 import { GraphQLDateTime } from 'graphql-iso-date';
-import * as ms from 'ms';
 
 import { ApiModule } from './api/api.module';
 import { ConfigModule } from './config/config.module';

+ 1 - 0
server/src/config/config.service.mock.ts

@@ -28,6 +28,7 @@ export class MockConfigService implements MockClass<ConfigService> {
     };
     paymentOptions: {};
     emailOptions: {};
+    importExportOptions: {};
     orderMergeOptions = {};
     orderProcessOptions = {};
     customFields = {};

+ 5 - 0
server/src/config/config.service.ts

@@ -13,6 +13,7 @@ import {
     AuthOptions,
     EmailOptions,
     getConfig,
+    ImportExportOptions,
     OrderMergeOptions,
     OrderProcessOptions,
     PaymentOptions,
@@ -100,6 +101,10 @@ export class ConfigService implements VendureConfig {
         return this.activeConfig.emailOptions as Required<EmailOptions<any>>;
     }
 
+    get importExportOptions(): Required<ImportExportOptions> {
+        return this.activeConfig.importExportOptions as Required<ImportExportOptions>;
+    }
+
     get customFields(): CustomFields {
         return this.activeConfig.customFields;
     }

+ 3 - 0
server/src/config/default-config.ts

@@ -72,6 +72,9 @@ export const defaultConfig: ReadOnlyRequired<VendureConfig> = {
             type: 'none',
         },
     },
+    importExportOptions: {
+        importAssetsDir: __dirname,
+    },
     customFields: {
         Address: [],
         Customer: [],

+ 11 - 0
server/src/config/vendure-config.ts

@@ -182,6 +182,13 @@ export interface PaymentOptions {
     paymentMethodHandlers: Array<PaymentMethodHandler<any>>;
 }
 
+export interface ImportExportOptions {
+    /**
+     * The directory in which assets to be imported are located.
+     */
+    importAssetsDir?: string;
+}
+
 export interface VendureConfig {
     /**
      * The name of the property which contains the token of the
@@ -258,6 +265,10 @@ export interface VendureConfig {
      * Configures the handling of transactional emails.
      */
     emailOptions: EmailOptions<any>;
+    /**
+     * Configuration settings for data import and export.
+     */
+    importExportOptions?: ImportExportOptions;
     /**
      * Custom Express middleware for the server.
      */

+ 14 - 0
server/src/data-import/data-import.module.ts

@@ -0,0 +1,14 @@
+import { Module } from '@nestjs/common';
+
+import { ConfigModule } from '../config/config.module';
+import { ServiceModule } from '../service/service.module';
+
+import { ImportParser } from './providers/import-parser/import-parser';
+import { Importer } from './providers/importer/importer';
+
+@Module({
+    imports: [ServiceModule, ConfigModule],
+    exports: [ImportParser, Importer],
+    providers: [ImportParser, Importer],
+})
+export class DataImportModule {}

+ 49 - 0
server/src/data-import/import-cli.ts

@@ -0,0 +1,49 @@
+import * as path from 'path';
+
+import { devConfig } from '../../dev-config';
+import { SimpleGraphQLClient } from '../../mock-data/simple-graphql-client';
+import { bootstrap } from '../bootstrap';
+import { setConfig, VendureConfig } from '../config/vendure-config';
+
+// tslint:disable:no-console
+/**
+ * A CLI script which imports products from a CSV file.
+ */
+if (require.main === module) {
+    // Running from command line
+    const csvFile = process.argv[2];
+    const csvPath = path.join(__dirname, csvFile);
+    getClient()
+        .then(client => importProducts(client, csvPath))
+        .then(
+            () => process.exit(0),
+            err => {
+                console.log(err);
+                process.exit(1);
+            },
+        );
+}
+
+async function getClient() {
+    const config: VendureConfig = {
+        ...devConfig,
+        port: 3020,
+        authOptions: {
+            tokenMethod: 'bearer',
+        },
+        plugins: [],
+    };
+    (config.dbConnectionOptions as any).logging = false;
+    setConfig(config);
+    const app = await bootstrap(config);
+    const client = new SimpleGraphQLClient(`http://localhost:${config.port}/${config.apiPath}`);
+    client.setChannelToken(devConfig.defaultChannelToken || 'no-default-channel-token');
+    await client.asSuperAdmin();
+    return client;
+}
+
+async function importProducts(client: SimpleGraphQLClient, csvFile: string) {
+    console.log(`loading data from "${csvFile}"`);
+    const result = await client.importProducts(csvFile);
+    console.log(result);
+}

+ 425 - 0
server/src/data-import/providers/import-parser/__snapshots__/import-parser.spec.ts.snap

@@ -0,0 +1,425 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ImportParser parseProducts multiple products with multiple variants 1`] = `
+Array [
+  Object {
+    "product": Object {
+      "assetPaths": Array [],
+      "description": "A great device for stretching paper.",
+      "name": "Perfect Paper Stretcher",
+      "optionGroups": Array [
+        Object {
+          "name": "size",
+          "values": Array [
+            "Half Imperial",
+            "Quarter Imperial",
+            "Full Imperial",
+          ],
+        },
+      ],
+      "slug": "Perfect-paper-stretcher",
+    },
+    "variants": Array [
+      Object {
+        "assetPaths": Array [],
+        "optionValues": Array [
+          "Half Imperial",
+        ],
+        "price": 45.3,
+        "sku": "PPS12",
+        "taxCategory": "standard",
+      },
+      Object {
+        "assetPaths": Array [],
+        "optionValues": Array [
+          "Quarter Imperial",
+        ],
+        "price": 32.5,
+        "sku": "PPS14",
+        "taxCategory": "standard",
+      },
+      Object {
+        "assetPaths": Array [],
+        "optionValues": Array [
+          "Full Imperial",
+        ],
+        "price": 59.5,
+        "sku": "PPSF",
+        "taxCategory": "standard",
+      },
+    ],
+  },
+  Object {
+    "product": Object {
+      "assetPaths": Array [],
+      "description": "Mabef description",
+      "name": "Mabef M/02 Studio Easel",
+      "optionGroups": Array [],
+      "slug": "",
+    },
+    "variants": Array [
+      Object {
+        "assetPaths": Array [],
+        "optionValues": Array [],
+        "price": 910.7,
+        "sku": "M02",
+        "taxCategory": "standard",
+      },
+    ],
+  },
+  Object {
+    "product": Object {
+      "assetPaths": Array [],
+      "description": "Really mega pencils",
+      "name": "Giotto Mega Pencils",
+      "optionGroups": Array [
+        Object {
+          "name": "box size",
+          "values": Array [
+            "Box of 8",
+            "Box of 12",
+          ],
+        },
+      ],
+      "slug": "",
+    },
+    "variants": Array [
+      Object {
+        "assetPaths": Array [],
+        "optionValues": Array [
+          "Box of 8",
+        ],
+        "price": 4.16,
+        "sku": "225400",
+        "taxCategory": "standard",
+      },
+      Object {
+        "assetPaths": Array [],
+        "optionValues": Array [
+          "Box of 12",
+        ],
+        "price": 6.24,
+        "sku": "225600",
+        "taxCategory": "standard",
+      },
+    ],
+  },
+  Object {
+    "product": Object {
+      "assetPaths": Array [],
+      "description": "Keeps the paint off the clothes",
+      "name": "Artists Smock",
+      "optionGroups": Array [
+        Object {
+          "name": "size",
+          "values": Array [
+            "small",
+            "large",
+          ],
+        },
+        Object {
+          "name": "colour",
+          "values": Array [
+            "beige",
+            "navy",
+          ],
+        },
+      ],
+      "slug": "",
+    },
+    "variants": Array [
+      Object {
+        "assetPaths": Array [],
+        "optionValues": Array [
+          "small",
+          "beige",
+        ],
+        "price": 11.99,
+        "sku": "10112",
+        "taxCategory": "standard",
+      },
+      Object {
+        "assetPaths": Array [],
+        "optionValues": Array [
+          "large",
+          "beige",
+        ],
+        "price": 11.99,
+        "sku": "10113",
+        "taxCategory": "standard",
+      },
+      Object {
+        "assetPaths": Array [],
+        "optionValues": Array [
+          "small",
+          "navy",
+        ],
+        "price": 11.99,
+        "sku": "10114",
+        "taxCategory": "standard",
+      },
+      Object {
+        "assetPaths": Array [],
+        "optionValues": Array [
+          "large",
+          "navy",
+        ],
+        "price": 11.99,
+        "sku": "10115",
+        "taxCategory": "standard",
+      },
+    ],
+  },
+]
+`;
+
+exports[`ImportParser parseProducts single product with a multiple variants 1`] = `
+Array [
+  Object {
+    "product": Object {
+      "assetPaths": Array [],
+      "description": "A great device for stretching paper.",
+      "name": "Perfect Paper Stretcher",
+      "optionGroups": Array [
+        Object {
+          "name": "size",
+          "values": Array [
+            "Half Imperial",
+            "Quarter Imperial",
+            "Full Imperial",
+          ],
+        },
+      ],
+      "slug": "",
+    },
+    "variants": Array [
+      Object {
+        "assetPaths": Array [],
+        "optionValues": Array [
+          "Half Imperial",
+        ],
+        "price": 45.3,
+        "sku": "PPS12",
+        "taxCategory": "standard",
+      },
+      Object {
+        "assetPaths": Array [],
+        "optionValues": Array [
+          "Quarter Imperial",
+        ],
+        "price": 32.5,
+        "sku": "PPS14",
+        "taxCategory": "standard",
+      },
+      Object {
+        "assetPaths": Array [],
+        "optionValues": Array [
+          "Full Imperial",
+        ],
+        "price": 59.5,
+        "sku": "PPSF",
+        "taxCategory": "standard",
+      },
+    ],
+  },
+]
+`;
+
+exports[`ImportParser parseProducts single product with a single variant 1`] = `
+Array [
+  Object {
+    "product": Object {
+      "assetPaths": Array [
+        "pps1.jpg",
+        "pps2.jpg",
+      ],
+      "description": "A great device for stretching paper.",
+      "name": "Perfect Paper Stretcher",
+      "optionGroups": Array [],
+      "slug": "perfect-paper-stretcher",
+    },
+    "variants": Array [
+      Object {
+        "assetPaths": Array [],
+        "optionValues": Array [],
+        "price": 45.3,
+        "sku": "PPS12",
+        "taxCategory": "standard",
+      },
+    ],
+  },
+]
+`;
+
+exports[`ImportParser parseProducts works with streamed input 1`] = `
+Array [
+  Object {
+    "product": Object {
+      "assetPaths": Array [],
+      "description": "A great device for stretching paper.",
+      "name": "Perfect Paper Stretcher",
+      "optionGroups": Array [
+        Object {
+          "name": "size",
+          "values": Array [
+            "Half Imperial",
+            "Quarter Imperial",
+            "Full Imperial",
+          ],
+        },
+      ],
+      "slug": "Perfect-paper-stretcher",
+    },
+    "variants": Array [
+      Object {
+        "assetPaths": Array [],
+        "optionValues": Array [
+          "Half Imperial",
+        ],
+        "price": 45.3,
+        "sku": "PPS12",
+        "taxCategory": "standard",
+      },
+      Object {
+        "assetPaths": Array [],
+        "optionValues": Array [
+          "Quarter Imperial",
+        ],
+        "price": 32.5,
+        "sku": "PPS14",
+        "taxCategory": "standard",
+      },
+      Object {
+        "assetPaths": Array [],
+        "optionValues": Array [
+          "Full Imperial",
+        ],
+        "price": 59.5,
+        "sku": "PPSF",
+        "taxCategory": "standard",
+      },
+    ],
+  },
+  Object {
+    "product": Object {
+      "assetPaths": Array [],
+      "description": "Mabef description",
+      "name": "Mabef M/02 Studio Easel",
+      "optionGroups": Array [],
+      "slug": "",
+    },
+    "variants": Array [
+      Object {
+        "assetPaths": Array [],
+        "optionValues": Array [],
+        "price": 910.7,
+        "sku": "M02",
+        "taxCategory": "standard",
+      },
+    ],
+  },
+  Object {
+    "product": Object {
+      "assetPaths": Array [],
+      "description": "Really mega pencils",
+      "name": "Giotto Mega Pencils",
+      "optionGroups": Array [
+        Object {
+          "name": "box size",
+          "values": Array [
+            "Box of 8",
+            "Box of 12",
+          ],
+        },
+      ],
+      "slug": "",
+    },
+    "variants": Array [
+      Object {
+        "assetPaths": Array [],
+        "optionValues": Array [
+          "Box of 8",
+        ],
+        "price": 4.16,
+        "sku": "225400",
+        "taxCategory": "standard",
+      },
+      Object {
+        "assetPaths": Array [],
+        "optionValues": Array [
+          "Box of 12",
+        ],
+        "price": 6.24,
+        "sku": "225600",
+        "taxCategory": "standard",
+      },
+    ],
+  },
+  Object {
+    "product": Object {
+      "assetPaths": Array [],
+      "description": "Keeps the paint off the clothes",
+      "name": "Artists Smock",
+      "optionGroups": Array [
+        Object {
+          "name": "size",
+          "values": Array [
+            "small",
+            "large",
+          ],
+        },
+        Object {
+          "name": "colour",
+          "values": Array [
+            "beige",
+            "navy",
+          ],
+        },
+      ],
+      "slug": "",
+    },
+    "variants": Array [
+      Object {
+        "assetPaths": Array [],
+        "optionValues": Array [
+          "small",
+          "beige",
+        ],
+        "price": 11.99,
+        "sku": "10112",
+        "taxCategory": "standard",
+      },
+      Object {
+        "assetPaths": Array [],
+        "optionValues": Array [
+          "large",
+          "beige",
+        ],
+        "price": 11.99,
+        "sku": "10113",
+        "taxCategory": "standard",
+      },
+      Object {
+        "assetPaths": Array [],
+        "optionValues": Array [
+          "small",
+          "navy",
+        ],
+        "price": 11.99,
+        "sku": "10114",
+        "taxCategory": "standard",
+      },
+      Object {
+        "assetPaths": Array [],
+        "optionValues": Array [
+          "large",
+          "navy",
+        ],
+        "price": 11.99,
+        "sku": "10115",
+        "taxCategory": "standard",
+      },
+    ],
+  },
+]
+`;

+ 79 - 0
server/src/data-import/providers/import-parser/import-parser.spec.ts

@@ -0,0 +1,79 @@
+import * as fs from 'fs-extra';
+import * as path from 'path';
+
+import { ImportParser } from './import-parser';
+
+describe('ImportParser', () => {
+    describe('parseProducts', () => {
+        it('single product with a single variant', async () => {
+            const importParser = new ImportParser();
+
+            const input = await loadTestFixture('single-product-single-variant.csv');
+            const result = await importParser.parseProducts(input);
+
+            expect(result).toMatchSnapshot();
+        });
+
+        it('single product with a multiple variants', async () => {
+            const importParser = new ImportParser();
+
+            const input = await loadTestFixture('single-product-multiple-variants.csv');
+            const result = await importParser.parseProducts(input);
+
+            expect(result).toMatchSnapshot();
+        });
+
+        it('multiple products with multiple variants', async () => {
+            const importParser = new ImportParser();
+
+            const input = await loadTestFixture('multiple-products-multiple-variants.csv');
+            const result = await importParser.parseProducts(input);
+
+            expect(result).toMatchSnapshot();
+        });
+
+        it('works with streamed input', async () => {
+            const importParser = new ImportParser();
+
+            const filename = path.join(__dirname, 'test-fixtures', 'multiple-products-multiple-variants.csv');
+            const input = fs.createReadStream(filename);
+            const result = await importParser.parseProducts(input);
+
+            expect(result).toMatchSnapshot();
+        });
+
+        describe('error conditions', () => {
+            it('throws on invalid option values', async () => {
+                const importParser = new ImportParser();
+
+                const input = await loadTestFixture('invalid-option-values.csv');
+                try {
+                    await importParser.parseProducts(input);
+                    fail('should have thrown');
+                } catch (err) {
+                    expect(err.message).toBe(
+                        'The number of optionValues must match the number of optionGroups for the product "Artists Smock"',
+                    );
+                }
+            });
+
+            it('throws on ivalid columns', async () => {
+                const importParser = new ImportParser();
+
+                const input = await loadTestFixture('invalid-columns.csv');
+                try {
+                    await importParser.parseProducts(input);
+                    fail('should have thrown');
+                } catch (err) {
+                    expect(err.message).toBe(
+                        'The import file is missing the following columns: "slug", "assets"',
+                    );
+                }
+            });
+        });
+    });
+});
+
+function loadTestFixture(fileName: string): Promise<string> {
+    return fs.readFile(path.join(__dirname, 'test-fixtures', fileName), 'utf-8');
+}

+ 204 - 0
server/src/data-import/providers/import-parser/import-parser.ts

@@ -0,0 +1,204 @@
+import { Injectable } from '@nestjs/common';
+import * as parse from 'csv-parse';
+import { Stream } from 'stream';
+
+export interface RawProductRecord {
+    name?: string;
+    slug?: string;
+    description?: string;
+    assets?: string;
+    optionGroups?: string;
+    optionValues?: string;
+    sku?: string;
+    price?: string;
+    taxCategory?: string;
+    variantAssets?: string;
+}
+
+export interface ParsedProductVariant {
+    optionValues: string[];
+    sku: string;
+    price: number;
+    taxCategory: string;
+    assetPaths: string[];
+}
+
+export interface ParsedProduct {
+    name: string;
+    slug: string;
+    description: string;
+    assetPaths: string[];
+    optionGroups: Array<{
+        name: string;
+        values: string[];
+    }>;
+}
+
+export interface ParsedProductWithVariants {
+    product: ParsedProduct;
+    variants: ParsedProductVariant[];
+}
+
+/**
+ * Validates and parses CSV files into a data structure which can then be used to created new entities.
+ */
+@Injectable()
+export class ImportParser {
+    async parseProducts(input: string | Stream): Promise<ParsedProductWithVariants[]> {
+        const options: parse.Options = {
+            columns: true,
+            trim: true,
+        };
+        return new Promise<ParsedProductWithVariants[]>((resolve, reject) => {
+            if (typeof input === 'string') {
+                parse(input, options, (err, records: RawProductRecord[]) => {
+                    if (err) {
+                        reject(err);
+                    }
+
+                    try {
+                        const output = this.processRawRecords(records);
+                        resolve(output);
+                    } catch (err) {
+                        reject(err);
+                    }
+                });
+            } else {
+                const parser = parse(options);
+                const records: RawProductRecord[] = [];
+                // input.on('open', () => input.pipe(parser));
+                input.pipe(parser);
+                parser.on('readable', () => {
+                    let record;
+                    // tslint:disable-next-line:no-conditional-assignment
+                    while ((record = parser.read())) {
+                        records.push(record);
+                    }
+                });
+                parser.on('error', reject);
+                parser.on('end', () => {
+                    try {
+                        const output = this.processRawRecords(records);
+                        resolve(output);
+                    } catch (err) {
+                        reject(err);
+                    }
+                });
+            }
+        });
+    }
+
+    private processRawRecords(records: RawProductRecord[]): ParsedProductWithVariants[] {
+        const output: ParsedProductWithVariants[] = [];
+        let currentRow: ParsedProductWithVariants | undefined;
+        validateColumns(records[0]);
+        for (const r of records) {
+            if (r.name) {
+                if (currentRow) {
+                    populateOptionGroupValues(currentRow);
+                    output.push(currentRow);
+                }
+                currentRow = {
+                    product: parseProductFromRecord(r),
+                    variants: [parseVariantFromRecord(r)],
+                };
+            } else {
+                if (currentRow) {
+                    currentRow.variants.push(parseVariantFromRecord(r));
+                }
+            }
+            validateOptionValueCount(r, currentRow);
+        }
+        if (currentRow) {
+            populateOptionGroupValues(currentRow);
+            output.push(currentRow);
+        }
+        return output;
+    }
+}
+
+function populateOptionGroupValues(currentRow: ParsedProductWithVariants) {
+    const values = currentRow.variants.map(v => v.optionValues);
+    currentRow.product.optionGroups.forEach((og, i) => {
+        const uniqueValues = Array.from(new Set(values.map(v => v[i])));
+        og.values = uniqueValues;
+    });
+}
+
+function validateColumns(r: RawProductRecord) {
+    const requiredColumns: Array<keyof RawProductRecord> = [
+        'name',
+        'slug',
+        'description',
+        'assets',
+        'optionGroups',
+        'optionValues',
+        'sku',
+        'price',
+        'taxCategory',
+        'variantAssets',
+    ];
+    const rowKeys = Object.keys(r);
+    const missing: string[] = [];
+    for (const col of requiredColumns) {
+        if (!rowKeys.includes(col)) {
+            missing.push(col);
+        }
+    }
+    if (missing.length) {
+        throw new Error(
+            `The import file is missing the following columns: ${missing.map(m => `"${m}"`).join(', ')}`,
+        );
+    }
+}
+
+function validateOptionValueCount(r: RawProductRecord, currentRow?: ParsedProductWithVariants) {
+    if (!currentRow) {
+        return;
+    }
+    const optionValues = parseStringArray(r.optionValues);
+    if (currentRow.product.optionGroups.length !== optionValues.length) {
+        throw new Error(
+            `The number of optionValues must match the number of optionGroups for the product "${r.name}"`,
+        );
+    }
+}
+
+function parseProductFromRecord(r: RawProductRecord): ParsedProduct {
+    return {
+        name: parseString(r.name),
+        slug: parseString(r.slug),
+        description: parseString(r.description),
+        assetPaths: parseStringArray(r.assets),
+        optionGroups: parseStringArray(r.optionGroups).map(name => ({
+            name,
+            values: [],
+        })),
+    };
+}
+
+function parseVariantFromRecord(r: RawProductRecord): ParsedProductVariant {
+    return {
+        optionValues: parseStringArray(r.optionValues),
+        sku: parseString(r.sku),
+        price: parseNumber(r.price),
+        taxCategory: parseString(r.taxCategory),
+        assetPaths: parseStringArray(r.variantAssets),
+    };
+}
+
+function parseString(input?: string): string {
+    return (input || '').trim();
+}
+
+function parseNumber(input?: string): number {
+    return +(input || '').trim();
+}
+
+function parseStringArray(input?: string, separator = ','): string[] {
+    return (input || '')
+        .trim()
+        .split(separator)
+        .map(s => s.trim())
+        .filter(s => s !== '');
+}

+ 5 - 0
server/src/data-import/providers/import-parser/test-fixtures/invalid-columns.csv

@@ -0,0 +1,5 @@
+"name"          , "description"                     , "optionGroups" , "optionValues" , "sku"   , "price" , "taxCategory" , "variantAssets"
+"Artists Smock" , "Keeps the paint off the clothes" , "size, colour" , "small, beige" , "10112" , "11.99" , "standard"    , ""
+""              , ""                                , ""             , "large, beige" , "10113" , "11.99" , "standard"    , ""
+""              , ""                                , ""             , "small, navy"  , "10114" , "11.99" , "standard"    , ""
+""              , ""                                , ""             , "large, navy"  , "10115" , "11.99" , "standard"    , ""

+ 5 - 0
server/src/data-import/providers/import-parser/test-fixtures/invalid-option-values.csv

@@ -0,0 +1,5 @@
+"name"          , "slug" , "description"                     , "assets" , "optionGroups" , "optionValues" , "sku"   , "price" , "taxCategory" , "variantAssets"
+"Artists Smock" , ""     , "Keeps the paint off the clothes" , ""       , "size"         , "small, beige" , "10112" , "11.99" , "standard"    , ""
+""              , ""     , ""                                , ""       , ""             , "large, beige" , "10113" , "11.99" , "standard"    , ""
+""              , ""     , ""                                , ""       , ""             , "small, navy"  , "10114" , "11.99" , "standard"    , ""
+""              , ""     , ""                                , ""       , ""             , "large, navy"  , "10115" , "11.99" , "standard"    , ""

+ 11 - 0
server/src/data-import/providers/import-parser/test-fixtures/multiple-products-multiple-variants.csv

@@ -0,0 +1,11 @@
+name                    , slug                    , description                          , assets , optionGroups   , optionValues     , sku    , price , taxCategory , variantAssets
+Perfect Paper Stretcher , Perfect-paper-stretcher , A great device for stretching paper. ,        , size           , Half Imperial    , PPS12  , 45.3  , standard    ,
+                        ,                         ,                                      ,        ,                , Quarter Imperial , PPS14  , 32.5  , standard    ,
+                        ,                         ,                                      ,        ,                , Full Imperial    , PPSF   , 59.5  , standard    ,
+Mabef M/02 Studio Easel ,                         , Mabef description                    ,        ,                ,                  , M02    , 910.7 , standard    ,
+Giotto Mega Pencils     ,                         , Really mega pencils                  ,        , box size       , Box of 8         , 225400 , 4.16  , standard    ,
+                        ,                         ,                                      ,        ,                , Box of 12        , 225600 , 6.24  , standard    ,
+Artists Smock           ,                         , Keeps the paint off the clothes      ,        , "size, colour" , "small, beige"   , 10112  , 11.99 , standard    ,
+                        ,                         ,                                      ,        ,                , "large, beige"   , 10113  , 11.99 , standard    ,
+                        ,                         ,                                      ,        ,                , "small, navy"    , 10114  , 11.99 , standard    ,
+                        ,                         ,                                      ,        ,                , "large, navy"    , 10115  , 11.99 , standard    ,

+ 4 - 0
server/src/data-import/providers/import-parser/test-fixtures/single-product-multiple-variants.csv

@@ -0,0 +1,4 @@
+name                    , slug , description                          , assets , optionGroups , optionValues     , sku   , price , taxCategory , variantAssets
+Perfect Paper Stretcher ,      , A great device for stretching paper. ,        , size         , Half Imperial    , PPS12 , 45.3  , standard    ,
+                        ,      ,                                      ,        ,              , Quarter Imperial , PPS14 , 32.5  , standard    ,
+                        ,      ,                                      ,        ,              , Full Imperial    , PPSF  , 59.5  , standard    ,

+ 2 - 0
server/src/data-import/providers/import-parser/test-fixtures/single-product-single-variant.csv

@@ -0,0 +1,2 @@
+name                    , slug                    , description                          , assets , optionGroups , optionValues , sku   , price , taxCategory , variantAssets
+Perfect Paper Stretcher , perfect-paper-stretcher , A great device for stretching paper. , "pps1.jpg, pps2.jpg" ,              ,              , PPS12 , 45.3  , standard    ,

+ 120 - 0
server/src/data-import/providers/importer/importer.ts

@@ -0,0 +1,120 @@
+import { Injectable } from '@nestjs/common';
+import * as fs from 'fs-extra';
+import * as path from 'path';
+import { normalizeString } from 'shared/normalize-string';
+
+import { RequestContext } from '../../../api/common/request-context';
+import { ConfigService } from '../../../config/config.service';
+import { Asset } from '../../../entity/asset/asset.entity';
+import { TaxCategory } from '../../../entity/tax-category/tax-category.entity';
+import { AssetService } from '../../../service/services/asset.service';
+import { ProductOptionGroupService } from '../../../service/services/product-option-group.service';
+import { ProductOptionService } from '../../../service/services/product-option.service';
+import { ProductVariantService } from '../../../service/services/product-variant.service';
+import { ProductService } from '../../../service/services/product.service';
+import { TaxCategoryService } from '../../../service/services/tax-category.service';
+import { ParsedProductWithVariants } from '../import-parser/import-parser';
+
+@Injectable()
+export class Importer {
+    private taxCategoryMatches: { [name: string]: string } = {};
+
+    constructor(
+        private configService: ConfigService,
+        private productService: ProductService,
+        private productVariantService: ProductVariantService,
+        private productOptionGroupService: ProductOptionGroupService,
+        private assetService: AssetService,
+        private taxCategoryService: TaxCategoryService,
+        private productOptionService: ProductOptionService,
+    ) {}
+
+    async importProducts(ctx: RequestContext, rows: ParsedProductWithVariants[]) {
+        const languageCode = ctx.languageCode;
+        const taxCategories = await this.taxCategoryService.findAll();
+        for (const { product, variants } of rows) {
+            const productAssets = await this.createAssets(product.assetPaths);
+            const createdProduct = await this.productService.create(ctx, {
+                featuredAssetId: productAssets.length ? (productAssets[0].id as string) : undefined,
+                assetIds: productAssets.map(a => a.id) as string[],
+                translations: [
+                    {
+                        languageCode,
+                        name: product.name,
+                        description: product.description,
+                        slug: product.slug,
+                    },
+                ],
+            });
+
+            for (const optionGroup of product.optionGroups) {
+                const code = normalizeString(`${product.name}-${optionGroup.name}`, '-');
+                const group = await this.productOptionGroupService.create({
+                    code,
+                    options: optionGroup.values.map(name => ({} as any)),
+                    translations: [
+                        {
+                            languageCode,
+                            name: optionGroup.name,
+                        },
+                    ],
+                });
+                for (const option of optionGroup.values) {
+                    await this.productOptionService.create(group, {
+                        code: normalizeString(option, '-'),
+                        translations: [
+                            {
+                                languageCode,
+                                name: option,
+                            },
+                        ],
+                    });
+                }
+                await this.productService.addOptionGroupToProduct(ctx, createdProduct.id, group.id);
+            }
+
+            for (const variant of variants) {
+                // TODO: implement Assets for ProductVariants
+                await this.productVariantService.create(ctx, createdProduct, {
+                    sku: variant.sku,
+                    taxCategoryId: this.getMatchingTaxCategoryId(variant.taxCategory, taxCategories),
+                    optionCodes: variant.optionValues.map(v => normalizeString(v, '-')),
+                    translations: [
+                        {
+                            languageCode,
+                            name: [product.name, ...variant.optionValues].join(' '),
+                        },
+                    ],
+                    price: Math.round(variant.price * 100),
+                });
+            }
+        }
+    }
+
+    private async createAssets(assetPaths: string[]): Promise<Asset[]> {
+        const assets: Asset[] = [];
+        const { importAssetsDir } = this.configService.importExportOptions;
+        for (const assetPath of assetPaths) {
+            const filename = path.join(importAssetsDir, assetPath);
+            const stream = fs.createReadStream(filename);
+            const asset = await this.assetService.createFromFileStream(stream);
+            assets.push(asset);
+        }
+        return assets;
+    }
+
+    /**
+     * Attempts to match a TaxCategory entity against the name supplied in the import table. If no matches
+     * are found, the first TaxCategory id is returned.
+     */
+    private getMatchingTaxCategoryId(name: string, taxCategories: TaxCategory[]): string {
+        if (this.taxCategoryMatches[name]) {
+            return this.taxCategoryMatches[name];
+        }
+        const regex = new RegExp(name, 'i');
+        const found = taxCategories.find(tc => !!tc.name.match(regex));
+        const match = found ? found : taxCategories[0];
+        this.taxCategoryService[name] = match.id;
+        return match.id as string;
+    }
+}

+ 27 - 1
server/src/service/services/asset.service.ts

@@ -1,9 +1,14 @@
 import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
+import { ReadStream } from 'fs-extra';
+import * as mime from 'mime-types';
+import * as path from 'path';
 import { CreateAssetInput } from 'shared/generated-types';
 import { ID, PaginatedList } from 'shared/shared-types';
+import { Stream } from 'stream';
 import { Connection } from 'typeorm';
 
+import { InternalServerError } from '../../common/error/errors';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { getAssetType } from '../../common/utils';
 import { ConfigService } from '../../config/config.service';
@@ -36,8 +41,29 @@ export class AssetService {
             }));
     }
 
+    /**
+     * Create an Asset based on a file uploaded via the GraphQL API.
+     */
     async create(input: CreateAssetInput): Promise<Asset> {
-        const { stream, filename, mimetype, encoding } = await input.file;
+        const { stream, filename, mimetype } = await input.file;
+        return this.createAssetInternal(stream, filename, mimetype);
+    }
+
+    /**
+     * Create an Asset from a file stream created during data import.
+     */
+    async createFromFileStream(stream: ReadStream): Promise<Asset> {
+        const filePath = stream.path;
+        if (typeof filePath === 'string') {
+            const filename = path.basename(filePath);
+            const mimetype = mime.lookup(filename) || 'application/octet-stream';
+            return this.createAssetInternal(stream, filename, mimetype);
+        } else {
+            throw new InternalServerError(`error.path-should-be-a-string-got-buffer`);
+        }
+    }
+
+    private async createAssetInternal(stream: Stream, filename: string, mimetype: string): Promise<Asset> {
         const { assetOptions } = this.configService;
         const { assetPreviewStrategy, assetStorageStrategy } = assetOptions;
         const sourceFileName = await this.getSourceFileName(filename);

+ 29 - 0
server/yarn.lock

@@ -197,6 +197,13 @@
   dependencies:
     "@types/express" "*"
 
+"@types/csv-parse@^1.1.11":
+  version "1.1.11"
+  resolved "https://registry.yarnpkg.com/@types/csv-parse/-/csv-parse-1.1.11.tgz#47d68b5579e66aa0d55af9dbae76f1d2ff836f1d"
+  integrity sha512-c4kjQ7avmOV22T+xH2BAE/n4F52mGm+ELyHOk5PrGc/26Ps6RagP5lVKfDoLx+jmRnFKQlq6XIS33tp9Dco0Ug==
+  dependencies:
+    "@types/node" "*"
+
 "@types/events@*":
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/@types/events/-/events-1.2.0.tgz#81a6731ce4df43619e5c8c945383b3e62a89ea86"
@@ -262,6 +269,11 @@
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.0.tgz#719551d2352d301ac8b81db732acb6bdc28dbdef"
 
+"@types/mime-types@^2.1.0":
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/@types/mime-types/-/mime-types-2.1.0.tgz#9ca52cda363f699c69466c2a6ccdaad913ea7a73"
+  integrity sha1-nKUs2jY/aZxpRmwqbM2q2RPqenM=
+
 "@types/mime@*":
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.0.tgz#5a7306e367c539b9f6543499de8dd519fac37a8b"
@@ -1653,6 +1665,11 @@ cssom@0.3.x, "cssom@>= 0.3.2 < 0.4.0":
   dependencies:
     cssom "0.3.x"
 
+csv-parse@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/csv-parse/-/csv-parse-4.0.1.tgz#4ad438352cbf12d5317d0fb9d588e53473293851"
+  integrity sha512-ehkwejEj05wwO7Q9JD+YSI6dNMIauHIroNU1RALrmRrqPoZIwRnfBtgq5GkU6i2RxZOJqjo3dtI1NrVSXvaimA==
+
 d@1:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/d/-/d-1.0.0.tgz#754bb5bfe55451da69a58b94d45f4c5b0462d58f"
@@ -4281,12 +4298,24 @@ mime-db@~1.36.0:
   version "1.36.0"
   resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.36.0.tgz#5020478db3c7fe93aad7bbcc4dcf869c43363397"
 
+mime-db@~1.37.0:
+  version "1.37.0"
+  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.37.0.tgz#0b6a0ce6fdbe9576e25f1f2d2fde8830dc0ad0d8"
+  integrity sha512-R3C4db6bgQhlIhPU48fUtdVmKnflq+hRdad7IyKhtFj06VPNVdk2RhiYL3UjQIlso8L+YxAtFkobT0VK+S/ybg==
+
 mime-types@^2.1.12, mime-types@~2.1.17, mime-types@~2.1.18:
   version "2.1.18"
   resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.18.tgz#6f323f60a83d11146f831ff11fd66e2fe5503bb8"
   dependencies:
     mime-db "~1.33.0"
 
+mime-types@^2.1.21:
+  version "2.1.21"
+  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.21.tgz#28995aa1ecb770742fe6ae7e58f9181c744b3f96"
+  integrity sha512-3iL6DbwpyLzjR3xHSFNFeb9Nz/M8WDkX33t1GFQnFOllWk8pOrh/LSrB5OXlnlW5P9LH73X6loW/eogc+F5lJg==
+  dependencies:
+    mime-db "~1.37.0"
+
 mime-types@~2.1.19:
   version "2.1.20"
   resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.20.tgz#930cb719d571e903738520f8470911548ca2cc19"

+ 34 - 0
shared/generated-types.ts

@@ -631,6 +631,7 @@ export interface Mutation {
     updateFacet: Facet;
     createFacetValues: FacetValue[];
     updateFacetValues: FacetValue[];
+    importProducts?: ImportInfo | null;
     addItemToOrder?: Order | null;
     removeItemFromOrder?: Order | null;
     adjustItemQuantity?: Order | null;
@@ -676,6 +677,11 @@ export interface LoginResult {
     user: CurrentUser;
 }
 
+export interface ImportInfo {
+    errors?: string[] | null;
+    importedCount: number;
+}
+
 export interface AdministratorListOptions {
     take?: number | null;
     skip?: number | null;
@@ -1584,6 +1590,9 @@ export interface CreateFacetValuesMutationArgs {
 export interface UpdateFacetValuesMutationArgs {
     input: UpdateFacetValueInput[];
 }
+export interface ImportProductsMutationArgs {
+    csvFile: Upload;
+}
 export interface AddItemToOrderMutationArgs {
     productVariantId: string;
     quantity: number;
@@ -3887,6 +3896,7 @@ export namespace MutationResolvers {
         updateFacet?: UpdateFacetResolver<Facet, any, Context>;
         createFacetValues?: CreateFacetValuesResolver<FacetValue[], any, Context>;
         updateFacetValues?: UpdateFacetValuesResolver<FacetValue[], any, Context>;
+        importProducts?: ImportProductsResolver<ImportInfo | null, any, Context>;
         addItemToOrder?: AddItemToOrderResolver<Order | null, any, Context>;
         removeItemFromOrder?: RemoveItemFromOrderResolver<Order | null, any, Context>;
         adjustItemQuantity?: AdjustItemQuantityResolver<Order | null, any, Context>;
@@ -4177,6 +4187,16 @@ export namespace MutationResolvers {
         input: UpdateFacetValueInput[];
     }
 
+    export type ImportProductsResolver<R = ImportInfo | null, Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context,
+        ImportProductsArgs
+    >;
+    export interface ImportProductsArgs {
+        csvFile: Upload;
+    }
+
     export type AddItemToOrderResolver<R = Order | null, Parent = any, Context = any> = Resolver<
         R,
         Parent,
@@ -4568,6 +4588,20 @@ export namespace LoginResultResolvers {
     export type UserResolver<R = CurrentUser, Parent = any, Context = any> = Resolver<R, Parent, Context>;
 }
 
+export namespace ImportInfoResolvers {
+    export interface Resolvers<Context = any> {
+        errors?: ErrorsResolver<string[] | null, any, Context>;
+        importedCount?: ImportedCountResolver<number, any, Context>;
+    }
+
+    export type ErrorsResolver<R = string[] | null, Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context
+    >;
+    export type ImportedCountResolver<R = number, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+}
+
 export namespace GetAdministrators {
     export type Variables = {
         options?: AdministratorListOptions | null;

Некоторые файлы не были показаны из-за большого количества измененных файлов