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

feat(core): Add createProductVariant mutation & tests

Relates to #124

BREAKING CHANGE: The `generateVariantsForProduct` mutation has been removed
Michael Bromley 6 лет назад
Родитель
Сommit
9d74d9d349
25 измененных файлов с 737 добавлено и 357 удалено
  1. 1 0
      packages/common/src/generated-shop-types.ts
  2. 124 110
      packages/common/src/generated-types.ts
  3. 5 5
      packages/core/e2e/__snapshots__/import.e2e-spec.ts.snap
  4. 139 110
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  5. 1 0
      packages/core/e2e/graphql/generated-e2e-shop-types.ts
  6. 9 0
      packages/core/e2e/graphql/shared-definitions.ts
  7. 154 76
      packages/core/e2e/product.e2e-spec.ts
  8. 6 0
      packages/core/src/api/common/id-codec.ts
  9. 9 17
      packages/core/src/api/resolvers/admin/product.resolver.ts
  10. 23 1
      packages/core/src/api/resolvers/entity/product-variant-entity.resolver.ts
  11. 15 2
      packages/core/src/api/schema/admin-api/product.api.graphql
  12. 1 0
      packages/core/src/api/schema/type/product-option-group.type.graphql
  13. 2 1
      packages/core/src/data-import/providers/importer/importer.ts
  14. 5 1
      packages/core/src/entity/product-option-group/product-option-group.entity.ts
  15. 5 1
      packages/core/src/entity/product-option/product-option.entity.ts
  16. 3 3
      packages/core/src/entity/product-variant/product-variant-price.entity.ts
  17. 2 2
      packages/core/src/entity/product-variant/product-variant.subscriber.ts
  18. 3 3
      packages/core/src/entity/product/product.entity.ts
  19. 3 0
      packages/core/src/i18n/messages/en.json
  20. 56 0
      packages/core/src/service/helpers/utils/samples-each.spec.ts
  21. 22 0
      packages/core/src/service/helpers/utils/samples-each.ts
  22. 136 22
      packages/core/src/service/services/product-variant.service.ts
  23. 13 3
      packages/core/src/service/services/product.service.ts
  24. 0 0
      schema-admin.json
  25. 0 0
      schema-shop.json

+ 1 - 0
packages/common/src/generated-shop-types.ts

@@ -1593,6 +1593,7 @@ export type ProductOption = Node & {
     languageCode?: Maybe<LanguageCode>;
     code?: Maybe<Scalars['String']>;
     name?: Maybe<Scalars['String']>;
+    groupId: Scalars['ID'];
     translations: Array<ProductOptionTranslation>;
     customFields?: Maybe<Scalars['JSON']>;
 };

+ 124 - 110
packages/common/src/generated-types.ts

@@ -480,11 +480,12 @@ export type CreateProductOptionInput = {
 };
 
 export type CreateProductVariantInput = {
+  productId: Scalars['ID'],
   translations: Array<ProductVariantTranslationInput>,
   facetValueIds?: Maybe<Array<Scalars['ID']>>,
   sku: Scalars['String'],
   price?: Maybe<Scalars['Int']>,
-  taxCategoryId: Scalars['ID'],
+  taxCategoryId?: Maybe<Scalars['ID']>,
   optionIds?: Maybe<Array<Scalars['ID']>>,
   featuredAssetId?: Maybe<Scalars['ID']>,
   assetIds?: Maybe<Array<Scalars['ID']>>,
@@ -493,6 +494,12 @@ export type CreateProductVariantInput = {
   customFields?: Maybe<Scalars['JSON']>,
 };
 
+export type CreateProductVariantOptionInput = {
+  optionGroupId: Scalars['ID'],
+  code: Scalars['String'],
+  translations: Array<ProductOptionTranslationInput>,
+};
+
 export type CreatePromotionInput = {
   name: Scalars['String'],
   enabled: Scalars['Boolean'],
@@ -1537,10 +1544,10 @@ export type Mutation = {
   updateAdministrator: Administrator,
   /** Assign a Role to an Administrator */
   assignRoleToAdministrator: Administrator,
-  /** Create a new Asset */
-  createAssets: Array<Asset>,
   login: LoginResult,
   logout: Scalars['Boolean'],
+  /** Create a new Asset */
+  createAssets: Array<Asset>,
   /** Create a new Channel */
   createChannel: Channel,
   /** Update an existing Channel */
@@ -1551,14 +1558,6 @@ export type Mutation = {
   updateCollection: Collection,
   /** Move a Collection to a different parent or index */
   moveCollection: Collection,
-  /** Create a new CustomerGroup */
-  createCustomerGroup: CustomerGroup,
-  /** Update an existing CustomerGroup */
-  updateCustomerGroup: CustomerGroup,
-  /** Add Customers to a CustomerGroup */
-  addCustomersToGroup: CustomerGroup,
-  /** Remove Customers from a CustomerGroup */
-  removeCustomersFromGroup: CustomerGroup,
   /** Create a new Country */
   createCountry: Country,
   /** Update an existing Country */
@@ -1577,6 +1576,14 @@ export type Mutation = {
   updateCustomerAddress: Address,
   /** Update an existing Address */
   deleteCustomerAddress: Scalars['Boolean'],
+  /** Create a new CustomerGroup */
+  createCustomerGroup: CustomerGroup,
+  /** Update an existing CustomerGroup */
+  updateCustomerGroup: CustomerGroup,
+  /** Add Customers to a CustomerGroup */
+  addCustomersToGroup: CustomerGroup,
+  /** Remove Customers from a CustomerGroup */
+  removeCustomersFromGroup: CustomerGroup,
   /** Create a new Facet */
   createFacet: Facet,
   /** Update an existing Facet */
@@ -1597,13 +1604,16 @@ export type Mutation = {
   refundOrder: Refund,
   settleRefund: Refund,
   addNoteToOrder: Order,
-  /** Update an existing PaymentMethod */
-  updatePaymentMethod: PaymentMethod,
   /** Create a new ProductOptionGroup */
   createProductOptionGroup: ProductOptionGroup,
   /** Update an existing ProductOptionGroup */
   updateProductOptionGroup: ProductOptionGroup,
   reindex: JobInfo,
+  /** Update an existing PaymentMethod */
+  updatePaymentMethod: PaymentMethod,
+  createPromotion: Promotion,
+  updatePromotion: Promotion,
+  deletePromotion: DeletionResponse,
   /** Create a new Product */
   createProduct: Product,
   /** Update an existing Product */
@@ -1616,23 +1626,21 @@ export type Mutation = {
   removeOptionGroupFromProduct: Product,
   /** Create a set of ProductVariants based on the OptionGroups assigned to the given Product */
   generateVariantsForProduct: Product,
+  createProductVariants: Array<Maybe<ProductVariant>>,
   /** Update existing ProductVariants */
   updateProductVariants: Array<Maybe<ProductVariant>>,
-  createPromotion: Promotion,
-  updatePromotion: Promotion,
-  deletePromotion: DeletionResponse,
   /** Create a new Role */
   createRole: Role,
   /** Update an existing Role */
   updateRole: Role,
-  /** Create a new ShippingMethod */
-  createShippingMethod: ShippingMethod,
-  /** Update an existing ShippingMethod */
-  updateShippingMethod: ShippingMethod,
   /** Create a new TaxCategory */
   createTaxCategory: TaxCategory,
   /** Update an existing TaxCategory */
   updateTaxCategory: TaxCategory,
+  /** Create a new ShippingMethod */
+  createShippingMethod: ShippingMethod,
+  /** Update an existing ShippingMethod */
+  updateShippingMethod: ShippingMethod,
   /** Create a new TaxRate */
   createTaxRate: TaxRate,
   /** Update an existing TaxRate */
@@ -1666,11 +1674,6 @@ export type MutationAssignRoleToAdministratorArgs = {
 };
 
 
-export type MutationCreateAssetsArgs = {
-  input: Array<CreateAssetInput>
-};
-
-
 export type MutationLoginArgs = {
   username: Scalars['String'],
   password: Scalars['String'],
@@ -1678,6 +1681,11 @@ export type MutationLoginArgs = {
 };
 
 
+export type MutationCreateAssetsArgs = {
+  input: Array<CreateAssetInput>
+};
+
+
 export type MutationCreateChannelArgs = {
   input: CreateChannelInput
 };
@@ -1703,28 +1711,6 @@ export type MutationMoveCollectionArgs = {
 };
 
 
-export type MutationCreateCustomerGroupArgs = {
-  input: CreateCustomerGroupInput
-};
-
-
-export type MutationUpdateCustomerGroupArgs = {
-  input: UpdateCustomerGroupInput
-};
-
-
-export type MutationAddCustomersToGroupArgs = {
-  customerGroupId: Scalars['ID'],
-  customerIds: Array<Scalars['ID']>
-};
-
-
-export type MutationRemoveCustomersFromGroupArgs = {
-  customerGroupId: Scalars['ID'],
-  customerIds: Array<Scalars['ID']>
-};
-
-
 export type MutationCreateCountryArgs = {
   input: CreateCountryInput
 };
@@ -1772,6 +1758,28 @@ export type MutationDeleteCustomerAddressArgs = {
 };
 
 
+export type MutationCreateCustomerGroupArgs = {
+  input: CreateCustomerGroupInput
+};
+
+
+export type MutationUpdateCustomerGroupArgs = {
+  input: UpdateCustomerGroupInput
+};
+
+
+export type MutationAddCustomersToGroupArgs = {
+  customerGroupId: Scalars['ID'],
+  customerIds: Array<Scalars['ID']>
+};
+
+
+export type MutationRemoveCustomersFromGroupArgs = {
+  customerGroupId: Scalars['ID'],
+  customerIds: Array<Scalars['ID']>
+};
+
+
 export type MutationCreateFacetArgs = {
   input: CreateFacetInput
 };
@@ -1844,18 +1852,33 @@ export type MutationAddNoteToOrderArgs = {
 };
 
 
+export type MutationCreateProductOptionGroupArgs = {
+  input: CreateProductOptionGroupInput
+};
+
+
+export type MutationUpdateProductOptionGroupArgs = {
+  input: UpdateProductOptionGroupInput
+};
+
+
 export type MutationUpdatePaymentMethodArgs = {
   input: UpdatePaymentMethodInput
 };
 
 
-export type MutationCreateProductOptionGroupArgs = {
-  input: CreateProductOptionGroupInput
+export type MutationCreatePromotionArgs = {
+  input: CreatePromotionInput
 };
 
 
-export type MutationUpdateProductOptionGroupArgs = {
-  input: UpdateProductOptionGroupInput
+export type MutationUpdatePromotionArgs = {
+  input: UpdatePromotionInput
+};
+
+
+export type MutationDeletePromotionArgs = {
+  id: Scalars['ID']
 };
 
 
@@ -1894,23 +1917,13 @@ export type MutationGenerateVariantsForProductArgs = {
 };
 
 
-export type MutationUpdateProductVariantsArgs = {
-  input: Array<UpdateProductVariantInput>
+export type MutationCreateProductVariantsArgs = {
+  input: Array<CreateProductVariantInput>
 };
 
 
-export type MutationCreatePromotionArgs = {
-  input: CreatePromotionInput
-};
-
-
-export type MutationUpdatePromotionArgs = {
-  input: UpdatePromotionInput
-};
-
-
-export type MutationDeletePromotionArgs = {
-  id: Scalars['ID']
+export type MutationUpdateProductVariantsArgs = {
+  input: Array<UpdateProductVariantInput>
 };
 
 
@@ -1924,23 +1937,23 @@ export type MutationUpdateRoleArgs = {
 };
 
 
-export type MutationCreateShippingMethodArgs = {
-  input: CreateShippingMethodInput
+export type MutationCreateTaxCategoryArgs = {
+  input: CreateTaxCategoryInput
 };
 
 
-export type MutationUpdateShippingMethodArgs = {
-  input: UpdateShippingMethodInput
+export type MutationUpdateTaxCategoryArgs = {
+  input: UpdateTaxCategoryInput
 };
 
 
-export type MutationCreateTaxCategoryArgs = {
-  input: CreateTaxCategoryInput
+export type MutationCreateShippingMethodArgs = {
+  input: CreateShippingMethodInput
 };
 
 
-export type MutationUpdateTaxCategoryArgs = {
-  input: UpdateTaxCategoryInput
+export type MutationUpdateShippingMethodArgs = {
+  input: UpdateShippingMethodInput
 };
 
 
@@ -2269,6 +2282,7 @@ export type ProductOption = Node & {
   languageCode?: Maybe<LanguageCode>,
   code?: Maybe<Scalars['String']>,
   name?: Maybe<Scalars['String']>,
+  groupId: Scalars['ID'],
   translations: Array<ProductOptionTranslation>,
   customFields?: Maybe<Scalars['JSON']>,
 };
@@ -2477,21 +2491,21 @@ export type Query = {
   __typename?: 'Query',
   administrators: AdministratorList,
   administrator?: Maybe<Administrator>,
+  me?: Maybe<CurrentUser>,
   assets: AssetList,
   asset?: Maybe<Asset>,
-  me?: Maybe<CurrentUser>,
   channels: Array<Channel>,
   channel?: Maybe<Channel>,
   activeChannel: Channel,
   collections: CollectionList,
   collection?: Maybe<Collection>,
   collectionFilters: Array<ConfigurableOperation>,
-  customerGroups: Array<CustomerGroup>,
-  customerGroup?: Maybe<CustomerGroup>,
   countries: CountryList,
   country?: Maybe<Country>,
   customers: CustomerList,
   customer?: Maybe<Customer>,
+  customerGroups: Array<CustomerGroup>,
+  customerGroup?: Maybe<CustomerGroup>,
   facets: FacetList,
   facet?: Maybe<Facet>,
   globalSettings: GlobalSettings,
@@ -2499,25 +2513,25 @@ export type Query = {
   jobs: Array<JobInfo>,
   order?: Maybe<Order>,
   orders: OrderList,
-  paymentMethods: PaymentMethodList,
-  paymentMethod?: Maybe<PaymentMethod>,
   productOptionGroups: Array<ProductOptionGroup>,
   productOptionGroup?: Maybe<ProductOptionGroup>,
   search: SearchResponse,
-  products: ProductList,
-  /** Get a Product either by id or slug. If neither id nor slug is speicified, an error will result. */
-  product?: Maybe<Product>,
+  paymentMethods: PaymentMethodList,
+  paymentMethod?: Maybe<PaymentMethod>,
   promotion?: Maybe<Promotion>,
   promotions: PromotionList,
   adjustmentOperations: AdjustmentOperations,
+  products: ProductList,
+  /** Get a Product either by id or slug. If neither id nor slug is speicified, an error will result. */
+  product?: Maybe<Product>,
   roles: RoleList,
   role?: Maybe<Role>,
+  taxCategories: Array<TaxCategory>,
+  taxCategory?: Maybe<TaxCategory>,
   shippingMethods: ShippingMethodList,
   shippingMethod?: Maybe<ShippingMethod>,
   shippingEligibilityCheckers: Array<ConfigurableOperation>,
   shippingCalculators: Array<ConfigurableOperation>,
-  taxCategories: Array<TaxCategory>,
-  taxCategory?: Maybe<TaxCategory>,
   taxRates: TaxRateList,
   taxRate?: Maybe<TaxRate>,
   zones: Array<Zone>,
@@ -2562,11 +2576,6 @@ export type QueryCollectionArgs = {
 };
 
 
-export type QueryCustomerGroupArgs = {
-  id: Scalars['ID']
-};
-
-
 export type QueryCountriesArgs = {
   options?: Maybe<CountryListOptions>
 };
@@ -2587,6 +2596,11 @@ export type QueryCustomerArgs = {
 };
 
 
+export type QueryCustomerGroupArgs = {
+  id: Scalars['ID']
+};
+
+
 export type QueryFacetsArgs = {
   languageCode?: Maybe<LanguageCode>,
   options?: Maybe<FacetListOptions>
@@ -2619,16 +2633,6 @@ export type QueryOrdersArgs = {
 };
 
 
-export type QueryPaymentMethodsArgs = {
-  options?: Maybe<PaymentMethodListOptions>
-};
-
-
-export type QueryPaymentMethodArgs = {
-  id: Scalars['ID']
-};
-
-
 export type QueryProductOptionGroupsArgs = {
   languageCode?: Maybe<LanguageCode>,
   filterTerm?: Maybe<Scalars['String']>
@@ -2646,16 +2650,13 @@ export type QuerySearchArgs = {
 };
 
 
-export type QueryProductsArgs = {
-  languageCode?: Maybe<LanguageCode>,
-  options?: Maybe<ProductListOptions>
+export type QueryPaymentMethodsArgs = {
+  options?: Maybe<PaymentMethodListOptions>
 };
 
 
-export type QueryProductArgs = {
-  id?: Maybe<Scalars['ID']>,
-  slug?: Maybe<Scalars['String']>,
-  languageCode?: Maybe<LanguageCode>
+export type QueryPaymentMethodArgs = {
+  id: Scalars['ID']
 };
 
 
@@ -2669,6 +2670,19 @@ export type QueryPromotionsArgs = {
 };
 
 
+export type QueryProductsArgs = {
+  languageCode?: Maybe<LanguageCode>,
+  options?: Maybe<ProductListOptions>
+};
+
+
+export type QueryProductArgs = {
+  id?: Maybe<Scalars['ID']>,
+  slug?: Maybe<Scalars['String']>,
+  languageCode?: Maybe<LanguageCode>
+};
+
+
 export type QueryRolesArgs = {
   options?: Maybe<RoleListOptions>
 };
@@ -2679,17 +2693,17 @@ export type QueryRoleArgs = {
 };
 
 
-export type QueryShippingMethodsArgs = {
-  options?: Maybe<ShippingMethodListOptions>
+export type QueryTaxCategoryArgs = {
+  id: Scalars['ID']
 };
 
 
-export type QueryShippingMethodArgs = {
-  id: Scalars['ID']
+export type QueryShippingMethodsArgs = {
+  options?: Maybe<ShippingMethodListOptions>
 };
 
 
-export type QueryTaxCategoryArgs = {
+export type QueryShippingMethodArgs = {
   id: Scalars['ID']
 };
 

+ 5 - 5
packages/core/e2e/__snapshots__/import.e2e-spec.ts.snap

@@ -350,16 +350,16 @@ Array [
     "id": "T_4",
     "name": "Artists Smock",
     "optionGroups": Array [
-      Object {
-        "code": "artists-smock-size",
-        "id": "T_3",
-        "name": "size",
-      },
       Object {
         "code": "artists-smock-colour",
         "id": "T_4",
         "name": "colour",
       },
+      Object {
+        "code": "artists-smock-size",
+        "id": "T_3",
+        "name": "size",
+      },
     ],
     "slug": "artists-smock",
     "variants": Array [

+ 139 - 110
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -480,11 +480,12 @@ export type CreateProductOptionInput = {
 };
 
 export type CreateProductVariantInput = {
+    productId: Scalars['ID'];
     translations: Array<ProductVariantTranslationInput>;
     facetValueIds?: Maybe<Array<Scalars['ID']>>;
     sku: Scalars['String'];
     price?: Maybe<Scalars['Int']>;
-    taxCategoryId: Scalars['ID'];
+    taxCategoryId?: Maybe<Scalars['ID']>;
     optionIds?: Maybe<Array<Scalars['ID']>>;
     featuredAssetId?: Maybe<Scalars['ID']>;
     assetIds?: Maybe<Array<Scalars['ID']>>;
@@ -493,6 +494,12 @@ export type CreateProductVariantInput = {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type CreateProductVariantOptionInput = {
+    optionGroupId: Scalars['ID'];
+    code: Scalars['String'];
+    translations: Array<ProductOptionTranslationInput>;
+};
+
 export type CreatePromotionInput = {
     name: Scalars['String'];
     enabled: Scalars['Boolean'];
@@ -1534,10 +1541,10 @@ export type Mutation = {
     updateAdministrator: Administrator;
     /** Assign a Role to an Administrator */
     assignRoleToAdministrator: Administrator;
-    /** Create a new Asset */
-    createAssets: Array<Asset>;
     login: LoginResult;
     logout: Scalars['Boolean'];
+    /** Create a new Asset */
+    createAssets: Array<Asset>;
     /** Create a new Channel */
     createChannel: Channel;
     /** Update an existing Channel */
@@ -1548,14 +1555,6 @@ export type Mutation = {
     updateCollection: Collection;
     /** Move a Collection to a different parent or index */
     moveCollection: Collection;
-    /** Create a new CustomerGroup */
-    createCustomerGroup: CustomerGroup;
-    /** Update an existing CustomerGroup */
-    updateCustomerGroup: CustomerGroup;
-    /** Add Customers to a CustomerGroup */
-    addCustomersToGroup: CustomerGroup;
-    /** Remove Customers from a CustomerGroup */
-    removeCustomersFromGroup: CustomerGroup;
     /** Create a new Country */
     createCountry: Country;
     /** Update an existing Country */
@@ -1574,6 +1573,14 @@ export type Mutation = {
     updateCustomerAddress: Address;
     /** Update an existing Address */
     deleteCustomerAddress: Scalars['Boolean'];
+    /** Create a new CustomerGroup */
+    createCustomerGroup: CustomerGroup;
+    /** Update an existing CustomerGroup */
+    updateCustomerGroup: CustomerGroup;
+    /** Add Customers to a CustomerGroup */
+    addCustomersToGroup: CustomerGroup;
+    /** Remove Customers from a CustomerGroup */
+    removeCustomersFromGroup: CustomerGroup;
     /** Create a new Facet */
     createFacet: Facet;
     /** Update an existing Facet */
@@ -1594,13 +1601,16 @@ export type Mutation = {
     refundOrder: Refund;
     settleRefund: Refund;
     addNoteToOrder: Order;
-    /** Update an existing PaymentMethod */
-    updatePaymentMethod: PaymentMethod;
     /** Create a new ProductOptionGroup */
     createProductOptionGroup: ProductOptionGroup;
     /** Update an existing ProductOptionGroup */
     updateProductOptionGroup: ProductOptionGroup;
     reindex: JobInfo;
+    /** Update an existing PaymentMethod */
+    updatePaymentMethod: PaymentMethod;
+    createPromotion: Promotion;
+    updatePromotion: Promotion;
+    deletePromotion: DeletionResponse;
     /** Create a new Product */
     createProduct: Product;
     /** Update an existing Product */
@@ -1613,23 +1623,21 @@ export type Mutation = {
     removeOptionGroupFromProduct: Product;
     /** Create a set of ProductVariants based on the OptionGroups assigned to the given Product */
     generateVariantsForProduct: Product;
+    createProductVariants: Array<Maybe<ProductVariant>>;
     /** Update existing ProductVariants */
     updateProductVariants: Array<Maybe<ProductVariant>>;
-    createPromotion: Promotion;
-    updatePromotion: Promotion;
-    deletePromotion: DeletionResponse;
     /** Create a new Role */
     createRole: Role;
     /** Update an existing Role */
     updateRole: Role;
-    /** Create a new ShippingMethod */
-    createShippingMethod: ShippingMethod;
-    /** Update an existing ShippingMethod */
-    updateShippingMethod: ShippingMethod;
     /** Create a new TaxCategory */
     createTaxCategory: TaxCategory;
     /** Update an existing TaxCategory */
     updateTaxCategory: TaxCategory;
+    /** Create a new ShippingMethod */
+    createShippingMethod: ShippingMethod;
+    /** Update an existing ShippingMethod */
+    updateShippingMethod: ShippingMethod;
     /** Create a new TaxRate */
     createTaxRate: TaxRate;
     /** Update an existing TaxRate */
@@ -1659,16 +1667,16 @@ export type MutationAssignRoleToAdministratorArgs = {
     roleId: Scalars['ID'];
 };
 
-export type MutationCreateAssetsArgs = {
-    input: Array<CreateAssetInput>;
-};
-
 export type MutationLoginArgs = {
     username: Scalars['String'];
     password: Scalars['String'];
     rememberMe?: Maybe<Scalars['Boolean']>;
 };
 
+export type MutationCreateAssetsArgs = {
+    input: Array<CreateAssetInput>;
+};
+
 export type MutationCreateChannelArgs = {
     input: CreateChannelInput;
 };
@@ -1689,24 +1697,6 @@ export type MutationMoveCollectionArgs = {
     input: MoveCollectionInput;
 };
 
-export type MutationCreateCustomerGroupArgs = {
-    input: CreateCustomerGroupInput;
-};
-
-export type MutationUpdateCustomerGroupArgs = {
-    input: UpdateCustomerGroupInput;
-};
-
-export type MutationAddCustomersToGroupArgs = {
-    customerGroupId: Scalars['ID'];
-    customerIds: Array<Scalars['ID']>;
-};
-
-export type MutationRemoveCustomersFromGroupArgs = {
-    customerGroupId: Scalars['ID'];
-    customerIds: Array<Scalars['ID']>;
-};
-
 export type MutationCreateCountryArgs = {
     input: CreateCountryInput;
 };
@@ -1745,6 +1735,24 @@ export type MutationDeleteCustomerAddressArgs = {
     id: Scalars['ID'];
 };
 
+export type MutationCreateCustomerGroupArgs = {
+    input: CreateCustomerGroupInput;
+};
+
+export type MutationUpdateCustomerGroupArgs = {
+    input: UpdateCustomerGroupInput;
+};
+
+export type MutationAddCustomersToGroupArgs = {
+    customerGroupId: Scalars['ID'];
+    customerIds: Array<Scalars['ID']>;
+};
+
+export type MutationRemoveCustomersFromGroupArgs = {
+    customerGroupId: Scalars['ID'];
+    customerIds: Array<Scalars['ID']>;
+};
+
 export type MutationCreateFacetArgs = {
     input: CreateFacetInput;
 };
@@ -1803,10 +1811,6 @@ export type MutationAddNoteToOrderArgs = {
     input: AddNoteToOrderInput;
 };
 
-export type MutationUpdatePaymentMethodArgs = {
-    input: UpdatePaymentMethodInput;
-};
-
 export type MutationCreateProductOptionGroupArgs = {
     input: CreateProductOptionGroupInput;
 };
@@ -1815,6 +1819,22 @@ export type MutationUpdateProductOptionGroupArgs = {
     input: UpdateProductOptionGroupInput;
 };
 
+export type MutationUpdatePaymentMethodArgs = {
+    input: UpdatePaymentMethodInput;
+};
+
+export type MutationCreatePromotionArgs = {
+    input: CreatePromotionInput;
+};
+
+export type MutationUpdatePromotionArgs = {
+    input: UpdatePromotionInput;
+};
+
+export type MutationDeletePromotionArgs = {
+    id: Scalars['ID'];
+};
+
 export type MutationCreateProductArgs = {
     input: CreateProductInput;
 };
@@ -1844,20 +1864,12 @@ export type MutationGenerateVariantsForProductArgs = {
     defaultSku?: Maybe<Scalars['String']>;
 };
 
-export type MutationUpdateProductVariantsArgs = {
-    input: Array<UpdateProductVariantInput>;
-};
-
-export type MutationCreatePromotionArgs = {
-    input: CreatePromotionInput;
-};
-
-export type MutationUpdatePromotionArgs = {
-    input: UpdatePromotionInput;
+export type MutationCreateProductVariantsArgs = {
+    input: Array<CreateProductVariantInput>;
 };
 
-export type MutationDeletePromotionArgs = {
-    id: Scalars['ID'];
+export type MutationUpdateProductVariantsArgs = {
+    input: Array<UpdateProductVariantInput>;
 };
 
 export type MutationCreateRoleArgs = {
@@ -1868,14 +1880,6 @@ export type MutationUpdateRoleArgs = {
     input: UpdateRoleInput;
 };
 
-export type MutationCreateShippingMethodArgs = {
-    input: CreateShippingMethodInput;
-};
-
-export type MutationUpdateShippingMethodArgs = {
-    input: UpdateShippingMethodInput;
-};
-
 export type MutationCreateTaxCategoryArgs = {
     input: CreateTaxCategoryInput;
 };
@@ -1884,6 +1888,14 @@ export type MutationUpdateTaxCategoryArgs = {
     input: UpdateTaxCategoryInput;
 };
 
+export type MutationCreateShippingMethodArgs = {
+    input: CreateShippingMethodInput;
+};
+
+export type MutationUpdateShippingMethodArgs = {
+    input: UpdateShippingMethodInput;
+};
+
 export type MutationCreateTaxRateArgs = {
     input: CreateTaxRateInput;
 };
@@ -2202,6 +2214,7 @@ export type ProductOption = Node & {
     languageCode?: Maybe<LanguageCode>;
     code?: Maybe<Scalars['String']>;
     name?: Maybe<Scalars['String']>;
+    groupId: Scalars['ID'];
     translations: Array<ProductOptionTranslation>;
     customFields?: Maybe<Scalars['JSON']>;
 };
@@ -2409,21 +2422,21 @@ export type Query = {
     __typename?: 'Query';
     administrators: AdministratorList;
     administrator?: Maybe<Administrator>;
+    me?: Maybe<CurrentUser>;
     assets: AssetList;
     asset?: Maybe<Asset>;
-    me?: Maybe<CurrentUser>;
     channels: Array<Channel>;
     channel?: Maybe<Channel>;
     activeChannel: Channel;
     collections: CollectionList;
     collection?: Maybe<Collection>;
     collectionFilters: Array<ConfigurableOperation>;
-    customerGroups: Array<CustomerGroup>;
-    customerGroup?: Maybe<CustomerGroup>;
     countries: CountryList;
     country?: Maybe<Country>;
     customers: CustomerList;
     customer?: Maybe<Customer>;
+    customerGroups: Array<CustomerGroup>;
+    customerGroup?: Maybe<CustomerGroup>;
     facets: FacetList;
     facet?: Maybe<Facet>;
     globalSettings: GlobalSettings;
@@ -2431,25 +2444,25 @@ export type Query = {
     jobs: Array<JobInfo>;
     order?: Maybe<Order>;
     orders: OrderList;
-    paymentMethods: PaymentMethodList;
-    paymentMethod?: Maybe<PaymentMethod>;
     productOptionGroups: Array<ProductOptionGroup>;
     productOptionGroup?: Maybe<ProductOptionGroup>;
     search: SearchResponse;
-    products: ProductList;
-    /** Get a Product either by id or slug. If neither id nor slug is speicified, an error will result. */
-    product?: Maybe<Product>;
+    paymentMethods: PaymentMethodList;
+    paymentMethod?: Maybe<PaymentMethod>;
     promotion?: Maybe<Promotion>;
     promotions: PromotionList;
     adjustmentOperations: AdjustmentOperations;
+    products: ProductList;
+    /** Get a Product either by id or slug. If neither id nor slug is speicified, an error will result. */
+    product?: Maybe<Product>;
     roles: RoleList;
     role?: Maybe<Role>;
+    taxCategories: Array<TaxCategory>;
+    taxCategory?: Maybe<TaxCategory>;
     shippingMethods: ShippingMethodList;
     shippingMethod?: Maybe<ShippingMethod>;
     shippingEligibilityCheckers: Array<ConfigurableOperation>;
     shippingCalculators: Array<ConfigurableOperation>;
-    taxCategories: Array<TaxCategory>;
-    taxCategory?: Maybe<TaxCategory>;
     taxRates: TaxRateList;
     taxRate?: Maybe<TaxRate>;
     zones: Array<Zone>;
@@ -2486,10 +2499,6 @@ export type QueryCollectionArgs = {
     languageCode?: Maybe<LanguageCode>;
 };
 
-export type QueryCustomerGroupArgs = {
-    id: Scalars['ID'];
-};
-
 export type QueryCountriesArgs = {
     options?: Maybe<CountryListOptions>;
 };
@@ -2506,6 +2515,10 @@ export type QueryCustomerArgs = {
     id: Scalars['ID'];
 };
 
+export type QueryCustomerGroupArgs = {
+    id: Scalars['ID'];
+};
+
 export type QueryFacetsArgs = {
     languageCode?: Maybe<LanguageCode>;
     options?: Maybe<FacetListOptions>;
@@ -2532,14 +2545,6 @@ export type QueryOrdersArgs = {
     options?: Maybe<OrderListOptions>;
 };
 
-export type QueryPaymentMethodsArgs = {
-    options?: Maybe<PaymentMethodListOptions>;
-};
-
-export type QueryPaymentMethodArgs = {
-    id: Scalars['ID'];
-};
-
 export type QueryProductOptionGroupsArgs = {
     languageCode?: Maybe<LanguageCode>;
     filterTerm?: Maybe<Scalars['String']>;
@@ -2554,15 +2559,12 @@ export type QuerySearchArgs = {
     input: SearchInput;
 };
 
-export type QueryProductsArgs = {
-    languageCode?: Maybe<LanguageCode>;
-    options?: Maybe<ProductListOptions>;
+export type QueryPaymentMethodsArgs = {
+    options?: Maybe<PaymentMethodListOptions>;
 };
 
-export type QueryProductArgs = {
-    id?: Maybe<Scalars['ID']>;
-    slug?: Maybe<Scalars['String']>;
-    languageCode?: Maybe<LanguageCode>;
+export type QueryPaymentMethodArgs = {
+    id: Scalars['ID'];
 };
 
 export type QueryPromotionArgs = {
@@ -2573,6 +2575,17 @@ export type QueryPromotionsArgs = {
     options?: Maybe<PromotionListOptions>;
 };
 
+export type QueryProductsArgs = {
+    languageCode?: Maybe<LanguageCode>;
+    options?: Maybe<ProductListOptions>;
+};
+
+export type QueryProductArgs = {
+    id?: Maybe<Scalars['ID']>;
+    slug?: Maybe<Scalars['String']>;
+    languageCode?: Maybe<LanguageCode>;
+};
+
 export type QueryRolesArgs = {
     options?: Maybe<RoleListOptions>;
 };
@@ -2581,6 +2594,10 @@ export type QueryRoleArgs = {
     id: Scalars['ID'];
 };
 
+export type QueryTaxCategoryArgs = {
+    id: Scalars['ID'];
+};
+
 export type QueryShippingMethodsArgs = {
     options?: Maybe<ShippingMethodListOptions>;
 };
@@ -2589,10 +2606,6 @@ export type QueryShippingMethodArgs = {
     id: Scalars['ID'];
 };
 
-export type QueryTaxCategoryArgs = {
-    id: Scalars['ID'];
-};
-
 export type QueryTaxRatesArgs = {
     options?: Maybe<TaxRateListOptions>;
 };
@@ -3807,6 +3820,14 @@ export type GetProductListQuery = { __typename?: 'Query' } & {
         };
 };
 
+export type CreateProductVariantsMutationVariables = {
+    input: Array<CreateProductVariantInput>;
+};
+
+export type CreateProductVariantsMutation = { __typename?: 'Mutation' } & {
+    createProductVariants: Array<Maybe<{ __typename?: 'ProductVariant' } & ProductVariantFragment>>;
+};
+
 export type UpdateProductVariantsMutationVariables = {
     input: Array<UpdateProductVariantInput>;
 };
@@ -4215,15 +4236,16 @@ export type RemoveOptionGroupFromProductMutation = { __typename?: 'Mutation' } &
         };
 };
 
-export type GenerateProductVariantsMutationVariables = {
-    productId: Scalars['ID'];
-    defaultTaxCategoryId?: Maybe<Scalars['ID']>;
-    defaultPrice?: Maybe<Scalars['Int']>;
-    defaultSku?: Maybe<Scalars['String']>;
+export type GetOptionGroupQueryVariables = {
+    id: Scalars['ID'];
 };
 
-export type GenerateProductVariantsMutation = { __typename?: 'Mutation' } & {
-    generateVariantsForProduct: { __typename?: 'Product' } & ProductWithVariantsFragment;
+export type GetOptionGroupQuery = { __typename?: 'Query' } & {
+    productOptionGroup: Maybe<
+        { __typename?: 'ProductOptionGroup' } & Pick<ProductOptionGroup, 'id' | 'code'> & {
+                options: Array<{ __typename?: 'ProductOption' } & Pick<ProductOption, 'id' | 'code'>>;
+            }
+    >;
 };
 
 export type DeletePromotionMutationVariables = {
@@ -4918,6 +4940,12 @@ export namespace GetProductList {
     >;
 }
 
+export namespace CreateProductVariants {
+    export type Variables = CreateProductVariantsMutationVariables;
+    export type Mutation = CreateProductVariantsMutation;
+    export type CreateProductVariants = ProductVariantFragment;
+}
+
 export namespace UpdateProductVariants {
     export type Variables = UpdateProductVariantsMutationVariables;
     export type Mutation = UpdateProductVariantsMutation;
@@ -5219,10 +5247,11 @@ export namespace RemoveOptionGroupFromProduct {
     >;
 }
 
-export namespace GenerateProductVariants {
-    export type Variables = GenerateProductVariantsMutationVariables;
-    export type Mutation = GenerateProductVariantsMutation;
-    export type GenerateVariantsForProduct = ProductWithVariantsFragment;
+export namespace GetOptionGroup {
+    export type Variables = GetOptionGroupQueryVariables;
+    export type Query = GetOptionGroupQuery;
+    export type ProductOptionGroup = NonNullable<GetOptionGroupQuery['productOptionGroup']>;
+    export type Options = NonNullable<(NonNullable<GetOptionGroupQuery['productOptionGroup']>)['options'][0]>;
 }
 
 export namespace DeletePromotion {

+ 1 - 0
packages/core/e2e/graphql/generated-e2e-shop-types.ts

@@ -1593,6 +1593,7 @@ export type ProductOption = Node & {
     languageCode?: Maybe<LanguageCode>;
     code?: Maybe<Scalars['String']>;
     name?: Maybe<Scalars['String']>;
+    groupId: Scalars['ID'];
     translations: Array<ProductOptionTranslation>;
     customFields?: Maybe<Scalars['JSON']>;
 };

+ 9 - 0
packages/core/e2e/graphql/shared-definitions.ts

@@ -70,6 +70,15 @@ export const GET_PRODUCT_LIST = gql`
     }
 `;
 
+export const CREATE_PRODUCT_VARIANTS = gql`
+    mutation CreateProductVariants($input: [CreateProductVariantInput!]!) {
+        createProductVariants(input: $input) {
+            ...ProductVariant
+        }
+    }
+    ${PRODUCT_VARIANT_FRAGMENT}
+`;
+
 export const UPDATE_PRODUCT_VARIANTS = gql`
     mutation UpdateProductVariants($input: [UpdateProductVariantInput!]!) {
         updateProductVariants(input: $input) {

+ 154 - 76
packages/core/e2e/product.e2e-spec.ts

@@ -1,4 +1,6 @@
 import { omit } from '@vendure/common/lib/omit';
+import { pick } from '@vendure/common/lib/pick';
+import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 import gql from 'graphql-tag';
 import path from 'path';
 
@@ -7,10 +9,12 @@ import { PRODUCT_WITH_VARIANTS_FRAGMENT } from './graphql/fragments';
 import {
     AddOptionGroupToProduct,
     CreateProduct,
+    CreateProductVariants,
     DeleteProduct,
     DeletionResult,
     GenerateProductVariants,
     GetAssetList,
+    GetOptionGroup,
     GetProductList,
     GetProductSimple,
     GetProductWithVariants,
@@ -23,6 +27,7 @@ import {
 } from './graphql/generated-e2e-admin-types';
 import {
     CREATE_PRODUCT,
+    CREATE_PRODUCT_VARIANTS,
     DELETE_PRODUCT,
     GET_ASSET_LIST,
     GET_PRODUCT_LIST,
@@ -159,7 +164,7 @@ describe('Product resolver', () => {
             const { product } = await client.query<
                 GetProductWithVariants.Query,
                 GetProductWithVariants.Variables
-            >(GET_PRODUCT_WITH_VARIANTS, {
+                >(GET_PRODUCT_WITH_VARIANTS, {
                 languageCode: LanguageCode.en,
                 id: 'T_2',
             });
@@ -207,6 +212,7 @@ describe('Product resolver', () => {
 
     describe('product mutation', () => {
         let newProduct: ProductWithVariants.Fragment;
+        let newProductWithAssets: ProductWithVariants.Fragment;
 
         it('createProduct creates a new Product', async () => {
             const result = await client.query<CreateProduct.Mutation, CreateProduct.Variables>(
@@ -230,8 +236,8 @@ describe('Product resolver', () => {
                     },
                 },
             );
+            expect(result.createProduct).toMatchSnapshot();
             newProduct = result.createProduct;
-            expect(newProduct).toMatchSnapshot();
         });
 
         it('createProduct creates a new Product with assets', async () => {
@@ -260,6 +266,7 @@ describe('Product resolver', () => {
             );
             expect(result.createProduct.assets.map(a => a.id)).toEqual(assetIds);
             expect(result.createProduct.featuredAsset!.id).toBe(featuredAssetId);
+            newProductWithAssets = result.createProduct;
         });
 
         it('updateProduct updates a Product', async () => {
@@ -411,7 +418,7 @@ describe('Product resolver', () => {
             const productResult = await client.query<
                 GetProductWithVariants.Query,
                 GetProductWithVariants.Variables
-            >(GET_PRODUCT_WITH_VARIANTS, {
+                >(GET_PRODUCT_WITH_VARIANTS, {
                 id: newProduct.id,
                 languageCode: LanguageCode.en,
             });
@@ -473,7 +480,7 @@ describe('Product resolver', () => {
             const result = await client.query<
                 AddOptionGroupToProduct.Mutation,
                 AddOptionGroupToProduct.Variables
-            >(ADD_OPTION_GROUP_TO_PRODUCT, {
+                >(ADD_OPTION_GROUP_TO_PRODUCT, {
                 optionGroupId: 'T_2',
                 productId: newProduct.id,
             });
@@ -512,17 +519,41 @@ describe('Product resolver', () => {
         );
 
         it('removeOptionGroupFromProduct removes an option group', async () => {
+            const { addOptionGroupToProduct } = await client.query<
+                AddOptionGroupToProduct.Mutation,
+                AddOptionGroupToProduct.Variables
+                >(ADD_OPTION_GROUP_TO_PRODUCT, {
+                optionGroupId: 'T_1',
+                productId: newProductWithAssets.id,
+            });
+            expect(addOptionGroupToProduct.optionGroups.length).toBe(1);
+
             const result = await client.query<
                 RemoveOptionGroupFromProduct.Mutation,
                 RemoveOptionGroupFromProduct.Variables
-            >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
+                >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
                 optionGroupId: 'T_1',
-                productId: 'T_1',
+                productId: newProductWithAssets.id,
             });
-            expect(result.removeOptionGroupFromProduct.optionGroups.length).toBe(1);
-            expect(result.removeOptionGroupFromProduct.optionGroups[0].code).toBe('laptop-ram');
+            expect(result.removeOptionGroupFromProduct.id).toBe(newProductWithAssets.id);
+            expect(result.removeOptionGroupFromProduct.optionGroups.length).toBe(0);
         });
 
+        it(
+            'removeOptionGroupFromProduct errors if the optionGroup is being used by variants',
+            assertThrowsWithMessage(
+                () =>
+                    client.query<
+                        RemoveOptionGroupFromProduct.Mutation,
+                        RemoveOptionGroupFromProduct.Variables
+                        >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
+                        optionGroupId: 'T_3',
+                        productId: 'T_2',
+                    }),
+                `Cannot remove ProductOptionGroup "curvy-monitor-monitor-size" as it is used by 2 ProductVariants`,
+            ),
+        );
+
         it(
             'removeOptionGroupFromProduct errors with an invalid productId',
             assertThrowsWithMessage(
@@ -530,7 +561,7 @@ describe('Product resolver', () => {
                     client.query<
                         RemoveOptionGroupFromProduct.Mutation,
                         RemoveOptionGroupFromProduct.Variables
-                    >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
+                        >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
                         optionGroupId: '1',
                         productId: '999',
                     }),
@@ -538,59 +569,126 @@ describe('Product resolver', () => {
             ),
         );
 
+        it(
+            'removeOptionGroupFromProduct errors with an invalid optionGroupId',
+            assertThrowsWithMessage(
+                () =>
+                    client.query<
+                        RemoveOptionGroupFromProduct.Mutation,
+                        RemoveOptionGroupFromProduct.Variables
+                        >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
+                        optionGroupId: '999',
+                        productId: newProduct.id,
+                    }),
+                `No ProductOptionGroup with the id '999' could be found`,
+            ),
+        );
+
         describe('variants', () => {
-            let variants: ProductWithVariants.Variants[];
+            let variants: CreateProductVariants.CreateProductVariants[];
+            let optionGroup2: GetOptionGroup.ProductOptionGroup;
+            let optionGroup3: GetOptionGroup.ProductOptionGroup;
+
+            beforeAll(async () => {
+                await client.query<
+                    AddOptionGroupToProduct.Mutation,
+                    AddOptionGroupToProduct.Variables
+                    >(ADD_OPTION_GROUP_TO_PRODUCT, {
+                    optionGroupId: 'T_3',
+                    productId: newProduct.id,
+                });
+                const result1 = await client.query<GetOptionGroup.Query, GetOptionGroup.Variables>(GET_OPTION_GROUP, { id: 'T_2' });
+                const result2 = await client.query<GetOptionGroup.Query, GetOptionGroup.Variables>(GET_OPTION_GROUP, { id: 'T_3' });
+                optionGroup2 = result1.productOptionGroup!;
+                optionGroup3 = result2.productOptionGroup!;
+            });
 
             it(
-                'generateVariantsForProduct throws with an invalid productId',
-                assertThrowsWithMessage(
-                    () =>
-                        client.query<GenerateProductVariants.Mutation, GenerateProductVariants.Variables>(
-                            GENERATE_PRODUCT_VARIANTS,
+                'createProductVariants throws if optionIds not compatible with product',
+                assertThrowsWithMessage(async () => {
+                    await client.query<
+                        CreateProductVariants.Mutation,
+                        CreateProductVariants.Variables
+                        >(CREATE_PRODUCT_VARIANTS, {
+                        input: [
                             {
-                                productId: '999',
+                                productId: newProduct.id,
+                                sku: 'PV1',
+                                optionIds: [],
+                                translations: [{ languageCode: LanguageCode.en, name: 'Variant 1' }],
                             },
-                        ),
-                    `No Product with the id '999' could be found`,
-                ),
+                        ],
+                    });
+                }, 'ProductVariant optionIds must include one optionId from each of the groups: curvy-monitor-monitor-size, laptop-ram'),
             );
 
             it(
-                'generateVariantsForProduct throws with an invalid defaultTaxCategoryId',
-                assertThrowsWithMessage(
-                    () =>
-                        client.query<GenerateProductVariants.Mutation, GenerateProductVariants.Variables>(
-                            GENERATE_PRODUCT_VARIANTS,
+                'createProductVariants throws if optionIds are duplicated',
+                assertThrowsWithMessage(async () => {
+                    await client.query<
+                        CreateProductVariants.Mutation,
+                        CreateProductVariants.Variables
+                        >(CREATE_PRODUCT_VARIANTS, {
+                        input: [
                             {
                                 productId: newProduct.id,
-                                defaultTaxCategoryId: '999',
+                                sku: 'PV1',
+                                optionIds: [optionGroup2.options[0].id, optionGroup2.options[1].id],
+                                translations: [{ languageCode: LanguageCode.en, name: 'Variant 1' }],
                             },
-                        ),
-                    `No TaxCategory with the id '999' could be found`,
-                ),
+                        ],
+                    });
+                }, 'ProductVariant optionIds must include one optionId from each of the groups: curvy-monitor-monitor-size, laptop-ram'),
             );
 
-            it('generateVariantsForProduct generates variants', async () => {
-                const result = await client.query<
-                    GenerateProductVariants.Mutation,
-                    GenerateProductVariants.Variables
-                >(GENERATE_PRODUCT_VARIANTS, {
-                    productId: newProduct.id,
-                    defaultPrice: 123,
-                    defaultSku: 'ABC',
+            it('createProductVariants works', async () => {
+                const { createProductVariants } = await client.query<
+                    CreateProductVariants.Mutation,
+                    CreateProductVariants.Variables
+                    >(CREATE_PRODUCT_VARIANTS, {
+                    input: [
+                        {
+                            productId: newProduct.id,
+                            sku: 'PV1',
+                            optionIds: [optionGroup2.options[0].id, optionGroup3.options[0].id],
+                            translations: [{ languageCode: LanguageCode.en, name: 'Variant 1' }],
+                        },
+                    ],
                 });
-                variants = result.generateVariantsForProduct.variants;
-                expect(variants.length).toBe(2);
-                expect(variants[0].options.length).toBe(1);
-                expect(variants[1].options.length).toBe(1);
+                expect(createProductVariants[0]!.name).toBe('Variant 1');
+                expect(createProductVariants[0]!.options.map(pick(['id']))).toEqual([
+                    { id: optionGroup2.options[0].id },
+                    { id: optionGroup3.options[0].id },
+                ]);
+                variants = createProductVariants.filter(notNullOrUndefined);
             });
 
+            it('createProductVariants throws if options combination exists', assertThrowsWithMessage(
+                async () => {
+                    await client.query<
+                        CreateProductVariants.Mutation,
+                        CreateProductVariants.Variables
+                        >(CREATE_PRODUCT_VARIANTS, {
+                        input: [
+                            {
+                                productId: newProduct.id,
+                                sku: 'PV2',
+                                optionIds: [optionGroup2.options[0].id, optionGroup3.options[0].id],
+                                translations: [{ languageCode: LanguageCode.en, name: 'Variant 2' }],
+                            },
+                        ],
+                    });
+                },
+                'A ProductVariant already exists with the options: 16gb, 24-inch',
+                ),
+            );
+
             it('updateProductVariants updates variants', async () => {
                 const firstVariant = variants[0];
-                const result = await client.query<
+                const { updateProductVariants } = await client.query<
                     UpdateProductVariants.Mutation,
                     UpdateProductVariants.Variables
-                >(UPDATE_PRODUCT_VARIANTS, {
+                    >(UPDATE_PRODUCT_VARIANTS, {
                     input: [
                         {
                             id: firstVariant.id,
@@ -600,7 +698,7 @@ describe('Product resolver', () => {
                         },
                     ],
                 });
-                const updatedVariant = result.updateProductVariants[0];
+                const updatedVariant = updateProductVariants[0];
                 if (!updatedVariant) {
                     fail('no updated variant returned.');
                     return;
@@ -614,7 +712,7 @@ describe('Product resolver', () => {
                 const result = await client.query<
                     UpdateProductVariants.Mutation,
                     UpdateProductVariants.Variables
-                >(UPDATE_PRODUCT_VARIANTS, {
+                    >(UPDATE_PRODUCT_VARIANTS, {
                     input: [
                         {
                             id: firstVariant.id,
@@ -637,7 +735,7 @@ describe('Product resolver', () => {
                 const result = await client.query<
                     UpdateProductVariants.Mutation,
                     UpdateProductVariants.Variables
-                >(UPDATE_PRODUCT_VARIANTS, {
+                    >(UPDATE_PRODUCT_VARIANTS, {
                     input: [
                         {
                             id: firstVariant.id,
@@ -660,7 +758,7 @@ describe('Product resolver', () => {
                 const result = await client.query<
                     UpdateProductVariants.Mutation,
                     UpdateProductVariants.Variables
-                >(UPDATE_PRODUCT_VARIANTS, {
+                    >(UPDATE_PRODUCT_VARIANTS, {
                     input: [
                         {
                             id: firstVariant.id,
@@ -773,27 +871,13 @@ describe('Product resolver', () => {
                     client.query<
                         RemoveOptionGroupFromProduct.Mutation,
                         RemoveOptionGroupFromProduct.Variables
-                    >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
+                        >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
                         optionGroupId: 'T_1',
                         productId: productToDelete.id,
                     }),
                 `No Product with the id '1' could be found`,
             ),
         );
-
-        it(
-            'generateVariantsForProduct throws for deleted product',
-            assertThrowsWithMessage(
-                () =>
-                    client.query<GenerateProductVariants.Mutation, GenerateProductVariants.Variables>(
-                        GENERATE_PRODUCT_VARIANTS,
-                        {
-                            productId: productToDelete.id,
-                        },
-                    ),
-                `No Product with the id '1' could be found`,
-            ),
-        );
     });
 });
 
@@ -829,21 +913,15 @@ export const REMOVE_OPTION_GROUP_FROM_PRODUCT = gql`
     }
 `;
 
-export const GENERATE_PRODUCT_VARIANTS = gql`
-    mutation GenerateProductVariants(
-        $productId: ID!
-        $defaultTaxCategoryId: ID
-        $defaultPrice: Int
-        $defaultSku: String
-    ) {
-        generateVariantsForProduct(
-            productId: $productId
-            defaultTaxCategoryId: $defaultTaxCategoryId
-            defaultPrice: $defaultPrice
-            defaultSku: $defaultSku
-        ) {
-            ...ProductWithVariants
+export const GET_OPTION_GROUP = gql`
+    query GetOptionGroup($id: ID!) {
+        productOptionGroup(id: $id) {
+            id
+            code
+            options {
+                id
+                code
+            }
         }
     }
-    ${PRODUCT_WITH_VARIANTS_FRAGMENT}
 `;

+ 6 - 0
packages/core/src/api/common/id-codec.ts

@@ -106,6 +106,9 @@ export class IdCodec {
     }
 
     private transform<T>(target: T, transformFn: (input: any) => ID, transformKeys?: string[]): T {
+        if (target == null) {
+            return target;
+        }
         const clone = Object.assign({}, target);
         if (transformKeys) {
             for (const key of transformKeys) {
@@ -123,6 +126,9 @@ export class IdCodec {
     }
 
     private isSimpleObject(target: any): boolean {
+        if (!target) {
+            return true;
+        }
         const values = Object.values(target);
         for (const value of values) {
             if (this.isObject(value) || value === null) {

+ 9 - 17
packages/core/src/api/resolvers/admin/product.resolver.ts

@@ -3,8 +3,8 @@ import {
     DeletionResponse,
     MutationAddOptionGroupToProductArgs,
     MutationCreateProductArgs,
+    MutationCreateProductVariantsArgs,
     MutationDeleteProductArgs,
-    MutationGenerateVariantsForProductArgs,
     MutationRemoveOptionGroupFromProductArgs,
     MutationUpdateProductArgs,
     MutationUpdateProductVariantsArgs,
@@ -16,7 +16,6 @@ import { PaginatedList } from '@vendure/common/lib/shared-types';
 
 import { UserInputError } from '../../../common/error/errors';
 import { Translated } from '../../../common/types/locale-types';
-import { assertFound } from '../../../common/utils';
 import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
 import { Product } from '../../../entity/product/product.entity';
 import { FacetValueService } from '../../../service/services/facet-value.service';
@@ -113,21 +112,14 @@ export class ProductResolver {
     }
 
     @Mutation()
-    @Allow(Permission.CreateCatalog)
-    @Decode('productId', 'defaultTaxCategoryId')
-    async generateVariantsForProduct(
+    @Allow(Permission.UpdateCatalog)
+    @Decode('taxCategoryId', 'facetValueIds', 'featuredAssetId', 'assetIds', 'optionIds')
+    async createProductVariants(
         @Ctx() ctx: RequestContext,
-        @Args() args: MutationGenerateVariantsForProductArgs,
-    ): Promise<Translated<Product>> {
-        const { productId, defaultTaxCategoryId, defaultPrice, defaultSku } = args;
-        await this.productVariantService.generateVariantsForProduct(
-            ctx,
-            productId,
-            defaultTaxCategoryId,
-            defaultPrice,
-            defaultSku,
-        );
-        return assertFound(this.productService.findOne(ctx, productId));
+        @Args() args: MutationCreateProductVariantsArgs,
+    ): Promise<Array<Translated<ProductVariant>>> {
+        const { input } = args;
+        return Promise.all(input.map(i => this.productVariantService.create(ctx, i)));
     }
 
     @Mutation()
@@ -138,6 +130,6 @@ export class ProductResolver {
         @Args() args: MutationUpdateProductVariantsArgs,
     ): Promise<Array<Translated<ProductVariant>>> {
         const { input } = args;
-        return Promise.all(input.map(variant => this.productVariantService.update(ctx, variant)));
+        return Promise.all(input.map(i => this.productVariantService.update(ctx, i)));
     }
 }

+ 23 - 1
packages/core/src/api/resolvers/entity/product-variant-entity.resolver.ts

@@ -3,7 +3,7 @@ import { StockMovementListOptions } from '@vendure/common/lib/generated-types';
 import { PaginatedList } from '@vendure/common/lib/shared-types';
 
 import { Translated } from '../../../common/types/locale-types';
-import { FacetValue, ProductOption } from '../../../entity';
+import { Asset, FacetValue, ProductOption } from '../../../entity';
 import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
 import { StockMovement } from '../../../entity/stock-movement/stock-movement.entity';
 import { ProductVariantService } from '../../../service/services/product-variant.service';
@@ -17,6 +17,28 @@ import { Ctx } from '../../decorators/request-context.decorator';
 export class ProductVariantEntityResolver {
     constructor(private productVariantService: ProductVariantService) {}
 
+    @ResolveProperty()
+    async assets(
+        @Ctx() ctx: RequestContext,
+        @Parent() productVariant: ProductVariant,
+    ): Promise<Asset[]> {
+        if (productVariant.assets) {
+            return productVariant.assets;
+        }
+        return this.productVariantService.getAssetsForVariant(ctx, productVariant.id);
+    }
+
+    @ResolveProperty()
+    async featuredAsset(
+        @Ctx() ctx: RequestContext,
+        @Parent() productVariant: ProductVariant,
+    ): Promise<Asset> {
+        if (productVariant.featuredAsset) {
+            return productVariant.featuredAsset;
+        }
+        return this.productVariantService.getFeaturedAssetForVariant(ctx, productVariant.id);
+    }
+
     @ResolveProperty()
     async options(
         @Ctx() ctx: RequestContext,

+ 15 - 2
packages/core/src/api/schema/admin-api/product.api.graphql

@@ -21,7 +21,7 @@ type Mutation {
     removeOptionGroupFromProduct(productId: ID!, optionGroupId: ID!): Product!
 
     "Create a set of ProductVariants based on the OptionGroups assigned to the given Product"
-    generateVariantsForProduct(productId: ID!, defaultTaxCategoryId: ID, defaultPrice: Int, defaultSku: String): Product!
+    createProductVariants(input: [CreateProductVariantInput!]!): [ProductVariant]!
 
     "Update existing ProductVariants"
     updateProductVariants(input: [UpdateProductVariantInput!]!): [ProductVariant]!
@@ -77,12 +77,25 @@ input ProductVariantTranslationInput {
     name: String
 }
 
+input ProductOptionTranslationInput {
+    id: ID
+    languageCode: LanguageCode!
+    name: String
+}
+
+input CreateProductVariantOptionInput {
+    optionGroupId: ID!
+    code: String!
+    translations: [ProductOptionTranslationInput!]!
+}
+
 input CreateProductVariantInput {
+    productId: ID!
     translations: [ProductVariantTranslationInput!]!
     facetValueIds: [ID!]
     sku: String!
     price: Int
-    taxCategoryId: ID!
+    taxCategoryId: ID
     optionIds: [ID!]
     featuredAssetId: ID
     assetIds: [ID!]

+ 1 - 0
packages/core/src/api/schema/type/product-option-group.type.graphql

@@ -24,6 +24,7 @@ type ProductOption implements Node {
     languageCode: LanguageCode
     code: String
     name: String
+    groupId: ID!
     translations: [ProductOptionTranslation!]!
 }
 

+ 2 - 1
packages/core/src/data-import/providers/importer/importer.ts

@@ -208,7 +208,8 @@ export class Importer {
                 if (0 < variant.facets.length) {
                     facetValueIds = await this.getFacetValueIds(variant.facets, languageCode);
                 }
-                const createdVariant = await this.productVariantService.create(ctx, createdProduct, {
+                const createdVariant = await this.productVariantService.create(ctx, {
+                    productId: createdProduct.id as string,
                     facetValueIds,
                     featuredAssetId: variantAssets.length ? (variantAssets[0].id as string) : undefined,
                     assetIds: variantAssets.map(a => a.id) as string[],

+ 5 - 1
packages/core/src/entity/product-option-group/product-option-group.entity.ts

@@ -1,11 +1,12 @@
 import { DeepPartial } from '@vendure/common/lib/shared-types';
 import { HasCustomFields } from '@vendure/common/lib/shared-types';
-import { Column, Entity, OneToMany } from 'typeorm';
+import { Column, Entity, ManyToOne, OneToMany } from 'typeorm';
 
 import { LocaleString, Translatable, Translation } from '../../common/types/locale-types';
 import { VendureEntity } from '../base/base.entity';
 import { CustomProductOptionGroupFields } from '../custom-entity-fields';
 import { ProductOption } from '../product-option/product-option.entity';
+import { Product } from '../product/product.entity';
 
 import { ProductOptionGroupTranslation } from './product-option-group-translation.entity';
 
@@ -32,6 +33,9 @@ export class ProductOptionGroup extends VendureEntity implements Translatable, H
     @OneToMany(type => ProductOption, option => option.group)
     options: ProductOption[];
 
+    @ManyToOne(type => Product)
+    product: Product;
+
     @Column(type => CustomProductOptionGroupFields)
     customFields: CustomProductOptionGroupFields;
 }

+ 5 - 1
packages/core/src/entity/product-option/product-option.entity.ts

@@ -1,8 +1,9 @@
-import { DeepPartial } from '@vendure/common/lib/shared-types';
+import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
 import { HasCustomFields } from '@vendure/common/lib/shared-types';
 import { Column, Entity, ManyToOne, OneToMany } from 'typeorm';
 
 import { LocaleString, Translatable, Translation } from '../../common/types/locale-types';
+import { idType } from '../../config/config-helpers';
 import { VendureEntity } from '../base/base.entity';
 import { CustomProductOptionFields } from '../custom-entity-fields';
 import { ProductOptionGroup } from '../product-option-group/product-option-group.entity';
@@ -31,6 +32,9 @@ export class ProductOption extends VendureEntity implements Translatable, HasCus
     @ManyToOne(type => ProductOptionGroup, group => group.options)
     group: ProductOptionGroup;
 
+    @Column({ type: idType() })
+    groupId: ID;
+
     @Column(type => CustomProductOptionFields)
     customFields: CustomProductOptionFields;
 }

+ 3 - 3
packages/core/src/entity/product-variant/product-variant-price.entity.ts

@@ -1,8 +1,8 @@
-import { DeepPartial } from '@vendure/common/lib/shared-types';
+import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
 import { Column, Entity, ManyToOne } from 'typeorm';
 
+import { idType } from '../../config/config-helpers';
 import { VendureEntity } from '../base/base.entity';
-import { Channel } from '../channel/channel.entity';
 
 import { ProductVariant } from './product-variant.entity';
 
@@ -14,7 +14,7 @@ export class ProductVariantPrice extends VendureEntity {
 
     @Column() price: number;
 
-    @Column() channelId: number;
+    @Column({ type: idType() }) channelId: ID;
 
     @ManyToOne(type => ProductVariant, variant => variant.productVariantPrices)
     variant: ProductVariant;

+ 2 - 2
packages/core/src/entity/product-variant/product-variant.subscriber.ts

@@ -14,7 +14,7 @@ export class ProductVariantSubscriber implements EntitySubscriberInterface<Produ
         return ProductVariant;
     }
 
-    async afterInsert(event: InsertEvent<ProductVariant>) {
+    /*async afterInsert(event: InsertEvent<ProductVariant>) {
         const { channelId, taxCategoryId } = event.queryRunner.data;
         const price = event.entity.price || 0;
         if (channelId === undefined) {
@@ -40,5 +40,5 @@ export class ProductVariantSubscriber implements EntitySubscriberInterface<Produ
             variantPrice.price = event.entity.price || 0;
             await event.manager.save(variantPrice);
         }
-    }
+    }*/
 }

+ 3 - 3
packages/core/src/entity/product/product.entity.ts

@@ -1,5 +1,6 @@
 import { DeepPartial, HasCustomFields } from '@vendure/common/lib/shared-types';
-import { Column, Entity, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm';
+import { doc } from 'prettier';
+import { Column, Entity, JoinColumn, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm';
 
 import { ChannelAware, SoftDeletable } from '../../common/types/common-types';
 import { LocaleString, Translatable, Translation } from '../../common/types/locale-types';
@@ -52,8 +53,7 @@ export class Product extends VendureEntity
     @OneToMany(type => ProductVariant, variant => variant.product)
     variants: ProductVariant[];
 
-    @ManyToMany(type => ProductOptionGroup)
-    @JoinTable()
+    @OneToMany(type => ProductOptionGroup, optionGroup => optionGroup.product)
     optionGroups: ProductOptionGroup[];
 
     @ManyToMany(type => FacetValue)

+ 3 - 0
packages/core/src/i18n/messages/en.json

@@ -6,6 +6,7 @@
     "cannot-modify-role": "The role '{ roleCode }' cannot be modified",
     "cannot-create-sales-for-active-order": "Cannot create a Sale for an Order which is still active",
     "cannot-move-collection-into-self": "Cannot move a Collection into itself",
+    "cannot-remove-option-group-due-to-variants": "Cannot remove ProductOptionGroup \"{ code }\" as it is used by {count, plural, one {1 ProductVariant} other {# ProductVariants}}",
     "cannot-transition-order-from-to": "Cannot transition Order from \"{ fromState }\" to \"{ toState }\"",
     "cannot-transition-payment-from-to": "Cannot transition Payment from \"{ fromState }\" to \"{ toState }\"",
     "cannot-transition-refund-from-to": "Cannot transition Refund from \"{ fromState }\" to \"{ toState }\"",
@@ -36,6 +37,8 @@
     "password-reset-token-has-expired": "Password reset token has expired.",
     "password-reset-token-not-recognized": "Password reset token not recognized",
     "product-id-or-slug-must-be-provided": "Either the product id or slug must be provided",
+    "product-variant-option-ids-not-compatible": "ProductVariant optionIds must include one optionId from each of the groups: {groupNames}",
+    "product-variant-options-combination-already-exists": "A ProductVariant already exists with the options: {optionNames}",
     "refund-order-item-already-refunded": "Cannot refund an OrderItem which has already been refunded",
     "refund-order-lines-invalid-order-state": "Cannot refund an Order in the \"{ state }\" state",
     "refund-order-lines-nothing-to-refund": "Nothing to refund",

+ 56 - 0
packages/core/src/service/helpers/utils/samples-each.spec.ts

@@ -0,0 +1,56 @@
+import { samplesEach } from './samples-each';
+
+describe('samplesEach()', () => {
+
+    it('single group match', () => {
+        const result = samplesEach([1], [[1]]);
+        expect(result).toBe(true);
+    });
+
+    it('single group no match', () => {
+        const result = samplesEach([1], [[3, 4, 5]]);
+        expect(result).toBe(false);
+    });
+
+    it('does not sample all groups', () => {
+        const result = samplesEach([1, 3], [[0, 1, 3], [2, 5, 4]]);
+        expect(result).toBe(false);
+    });
+
+    it('two groups in order', () => {
+        const result = samplesEach([1, 4], [[0, 1, 3], [2, 5, 4]]);
+        expect(result).toBe(true);
+    });
+
+    it('two groups not in order', () => {
+        const result = samplesEach([1, 4], [[2, 5, 4], [0, 1, 3]]);
+        expect(result).toBe(true);
+    });
+
+    it('three groups in order', () => {
+        const result = samplesEach([1, 4, 'a'], [[0, 1, 3], [2, 5, 4], ['b', 'a']]);
+        expect(result).toBe(true);
+    });
+
+    it('three groups not in order', () => {
+        const result = samplesEach([1, 4, 'a'], [[2, 5, 4], ['b', 'a'], [0, 1, 3]]);
+        expect(result).toBe(true);
+    });
+
+    it('input is unchanged', () => {
+        const input = [1, 4, 'a'];
+        const result = samplesEach(input, [[2, 5, 4], ['b', 'a'], [0, 1, 3]]);
+        expect(result).toBe(true);
+        expect(input).toEqual([1, 4, 'a']);
+    });
+
+    it('empty input arrays', () => {
+        const result = samplesEach([], []);
+        expect(result).toBe(true);
+    });
+
+    it('length mismatch', () => {
+        const result = samplesEach([1, 4, 5], [[2, 5, 4], [0, 1, 3]]);
+        expect(result).toBe(false);
+    });
+});

+ 22 - 0
packages/core/src/service/helpers/utils/samples-each.ts

@@ -0,0 +1,22 @@
+/**
+ * Returns true if and only if exactly one item from each
+ * of the "groups" arrays appears in the "sample" array.
+ */
+export function samplesEach<T>(sample: T[], groups: T[][]): boolean {
+    if (sample.length !== groups.length) {
+        return false;
+    }
+    const groupMap = groups.reduce((map, group) => {
+        map.set(group, false);
+        return map;
+    }, new Map<T[], boolean>());
+    for (const item of sample) {
+        const unseenGroups = Array.from(groupMap.entries()).filter(([group, seen]) => !seen).map(e => e[0]);
+        for (const group of unseenGroups) {
+            if (group.includes(item)) {
+                groupMap.set(group, true);
+            }
+        }
+    }
+    return Array.from(groupMap.values()).every(v => !!v);
+}

+ 136 - 22
packages/core/src/service/services/product-variant.service.ts

@@ -12,7 +12,7 @@ import { ListQueryOptions } from '../../common/types/common-types';
 import { Translated } from '../../common/types/locale-types';
 import { assertFound, idsAreEqual } from '../../common/utils';
 import { ConfigService } from '../../config/config.service';
-import { TaxCategory } from '../../entity';
+import { Asset, ProductOptionGroup, ProductVariantPrice, TaxCategory } from '../../entity';
 import { FacetValue } from '../../entity/facet-value/facet-value.entity';
 import { ProductOption } from '../../entity/product-option/product-option.entity';
 import { ProductVariantTranslation } from '../../entity/product-variant/product-variant-translation.entity';
@@ -25,6 +25,7 @@ import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-build
 import { TaxCalculator } from '../helpers/tax-calculator/tax-calculator';
 import { TranslatableSaver } from '../helpers/translatable-saver/translatable-saver';
 import { getEntityOrThrow } from '../helpers/utils/get-entity-or-throw';
+import { samplesEach } from '../helpers/utils/samples-each';
 import { translateDeep } from '../helpers/utils/translate-entity';
 
 import { FacetValueService } from './facet-value.service';
@@ -111,17 +112,16 @@ export class ProductVariantService {
             qb.andWhere('product.enabled = :enabled', { enabled: true });
         }
 
-        return qb.getManyAndCount()
-            .then(async ([variants, totalItems]) => {
-                const items = variants.map(variant => {
-                    const variantWithPrices = this.applyChannelPriceAndTax(variant, ctx);
-                    return translateDeep(variantWithPrices, ctx.languageCode);
-                });
-                return {
-                    items,
-                    totalItems,
-                };
+        return qb.getManyAndCount().then(async ([variants, totalItems]) => {
+            const items = variants.map(variant => {
+                const variantWithPrices = this.applyChannelPriceAndTax(variant, ctx);
+                return translateDeep(variantWithPrices, ctx.languageCode);
             });
+            return {
+                items,
+                totalItems,
+            };
+        });
     }
 
     getOptionsForVariant(ctx: RequestContext, variantId: ID): Promise<Array<Translated<ProductOption>>> {
@@ -131,6 +131,20 @@ export class ProductVariantService {
             .then(variant => (!variant ? [] : variant.options.map(o => translateDeep(o, ctx.languageCode))));
     }
 
+    async getAssetsForVariant(ctx: RequestContext, variantId: ID): Promise<Asset[]> {
+        const variant = await getEntityOrThrow(this.connection, ProductVariant, variantId, {
+            relations: ['assets'],
+        });
+        return variant.assets;
+    }
+
+    async getFeaturedAssetForVariant(ctx: RequestContext, variantId: ID): Promise<Asset> {
+        const variant = await getEntityOrThrow(this.connection, ProductVariant, variantId, {
+            relations: ['featuredAsset'],
+        });
+        return variant.featuredAsset;
+    }
+
     getFacetValuesForVariant(ctx: RequestContext, variantId: ID): Promise<Array<Translated<FacetValue>>> {
         return this.connection
             .getRepository(ProductVariant)
@@ -140,11 +154,16 @@ export class ProductVariantService {
             );
     }
 
-    async create(
-        ctx: RequestContext,
-        product: Product,
-        input: CreateProductVariantInput,
-    ): Promise<ProductVariant> {
+    async create(ctx: RequestContext, input: CreateProductVariantInput): Promise<Translated<ProductVariant>> {
+        await this.validateVariantOptionIds(input);
+        if (!input.optionIds) {
+            input.optionIds = [];
+        }
+        if (input.price == null) {
+            input.price = 0;
+        }
+        input.taxCategoryId = (await this.getTaxCategoryForNewVariant(input.taxCategoryId)).id as string;
+
         const createdVariant = await this.translatableSaver.create({
             input,
             entityType: ProductVariant,
@@ -163,7 +182,7 @@ export class ProductVariantService {
                 if (input.trackInventory == null) {
                     variant.trackInventory = (await this.globalSettingsService.getSettings()).trackInventory;
                 }
-                variant.product = product;
+                variant.product = { id: input.productId } as any;
                 variant.taxCategory = { id: input.taxCategoryId } as any;
                 await this.assetUpdater.updateEntityAssets(variant, input);
             },
@@ -173,9 +192,19 @@ export class ProductVariantService {
             },
         });
         if (input.stockOnHand != null && input.stockOnHand !== 0) {
-            await this.stockMovementService.adjustProductVariantStock(createdVariant.id, 0, input.stockOnHand);
+            await this.stockMovementService.adjustProductVariantStock(
+                createdVariant.id,
+                0,
+                input.stockOnHand,
+            );
         }
-        return createdVariant;
+        const variantPrice = new ProductVariantPrice({
+            price: createdVariant.price,
+            channelId: ctx.channelId,
+        });
+        variantPrice.variant = createdVariant;
+        await this.connection.getRepository(ProductVariantPrice).save(variantPrice);
+        return await assertFound(this.findOne(ctx, createdVariant.id));
     }
 
     async update(ctx: RequestContext, input: UpdateProductVariantInput): Promise<Translated<ProductVariant>> {
@@ -198,7 +227,11 @@ export class ProductVariantService {
                     updatedVariant.facetValues = await this.facetValueService.findByIds(input.facetValueIds);
                 }
                 if (input.stockOnHand != null) {
-                    await this.stockMovementService.adjustProductVariantStock(existingVariant.id, existingVariant.stockOnHand, input.stockOnHand);
+                    await this.stockMovementService.adjustProductVariantStock(
+                        existingVariant.id,
+                        existingVariant.stockOnHand,
+                        input.stockOnHand,
+                    );
                 }
                 await this.assetUpdater.updateEntityAssets(updatedVariant, input);
             },
@@ -207,6 +240,20 @@ export class ProductVariantService {
                 taxCategoryId: input.taxCategoryId,
             },
         });
+        if (input.price != null) {
+            const variantPriceRepository = this.connection.getRepository(ProductVariantPrice);
+            const variantPrice = await variantPriceRepository.findOne({
+                where: {
+                    variant: input.id,
+                    channelId: ctx.channelId,
+                },
+            });
+            if (!variantPrice) {
+                throw new InternalServerError(`error.could-not-find-product-variant-price`);
+            }
+            variantPrice.price = input.price;
+            await variantPriceRepository.save(variantPrice);
+        }
         const variant = await assertFound(
             this.connection.manager.getRepository(ProductVariant).findOne(input.id, {
                 relations: [
@@ -220,7 +267,7 @@ export class ProductVariantService {
             }),
         );
         this.eventBus.publish(new CatalogModificationEvent(ctx, variant));
-        return translateDeep(this.applyChannelPriceAndTax(variant, ctx), DEFAULT_LANGUAGE_CODE, [
+        return translateDeep(this.applyChannelPriceAndTax(variant, ctx), ctx.languageCode, [
             'options',
             'facetValues',
             ['facetValues', 'facet'],
@@ -265,7 +312,8 @@ export class ProductVariantService {
         const variants: ProductVariant[] = [];
         for (const options of optionCombinations) {
             const name = this.createVariantName(productName, options);
-            const variant = await this.create(ctx, product, {
+            const variant = await this.create(ctx, {
+                productId: productId as string,
                 sku: defaultSku || 'sku-not-set',
                 price: defaultPrice || 0,
                 optionIds: options.map(o => o.id) as string[],
@@ -314,6 +362,72 @@ export class ProductVariantService {
         return variant;
     }
 
+    private async validateVariantOptionIds(input: CreateProductVariantInput) {
+        const product = await getEntityOrThrow(this.connection, Product, input.productId, {
+            relations: ['optionGroups', 'optionGroups.options', 'variants', 'variants.options'],
+        });
+        const optionIds = [...(input.optionIds || [])];
+
+        if (optionIds.length !== product.optionGroups.length) {
+            this.throwIncompatibleOptionsError(product.optionGroups);
+        }
+        if (!samplesEach(optionIds, product.optionGroups.map(g => g.options.map(o => o.id)))) {
+            this.throwIncompatibleOptionsError(product.optionGroups);
+        }
+        /*product.optionGroups.forEach((group, i) => {
+            const optionId = optionIds[i];
+            const index = group.options.findIndex(o => idsAreEqual(o.id, optionId));
+            if (-1 < index) {
+                optionIds[i] = '__matched__';
+            } else {
+                console.log('input', input);
+                console.log('optionIds', optionIds);
+                console.log('product.optionGroups', product.optionGroups);
+                console.log('product.optionGroups', product.optionGroups.map(g => g.options));
+                this.throwIncompatibleOptionsError(product.optionGroups);
+            }
+        });*/
+        product.variants.forEach(variant => {
+            const variantOptionIds = this.sortJoin(variant.options, ',', 'id');
+            const inputOptionIds = this.sortJoin(input.optionIds || [], ',');
+            if (variantOptionIds === inputOptionIds) {
+                throw new UserInputError('error.product-variant-options-combination-already-exists', {
+                    optionNames: this.sortJoin(variant.options, ', ', 'code'),
+                });
+            }
+        });
+    }
+
+    private throwIncompatibleOptionsError(optionGroups: ProductOptionGroup[]) {
+        throw new UserInputError('error.product-variant-option-ids-not-compatible', {
+            groupNames: this.sortJoin(optionGroups, ', ', 'code'),
+        });
+    }
+
+    private sortJoin<T>(arr: T[], glue: string, prop?: keyof T): string {
+        return arr
+            .map(x => prop ? x[prop] : x)
+            .sort()
+            .join(glue);
+    }
+
+    private async getTaxCategoryForNewVariant(
+        taxCategoryId: string | null | undefined,
+    ): Promise<TaxCategory> {
+        let taxCategory: TaxCategory;
+        if (taxCategoryId) {
+            taxCategory = await getEntityOrThrow(this.connection, TaxCategory, taxCategoryId);
+        } else {
+            const taxCategories = await this.taxCategoryService.findAll();
+            taxCategory = taxCategories[0];
+        }
+        if (!taxCategory) {
+            // there is no TaxCategory set up, so create a default
+            taxCategory = await this.taxCategoryService.create({ name: 'Standard Tax' });
+        }
+        return taxCategory;
+    }
+
     private createVariantName(productName: string, options: ProductOption[]): string {
         const optionsSuffix = options
             .map(option => {

+ 13 - 3
packages/core/src/service/services/product.service.ts

@@ -11,10 +11,10 @@ import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
 import { Connection } from 'typeorm';
 
 import { RequestContext } from '../../api/common/request-context';
-import { EntityNotFoundError } from '../../common/error/errors';
+import { EntityNotFoundError, UserInputError } from '../../common/error/errors';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { Translated } from '../../common/types/locale-types';
-import { assertFound } from '../../common/utils';
+import { assertFound, idsAreEqual } from '../../common/utils';
 import { ProductOptionGroup } from '../../entity/product-option-group/product-option-group.entity';
 import { ProductTranslation } from '../../entity/product/product-translation.entity';
 import { Product } from '../../entity/product/product.entity';
@@ -184,6 +184,16 @@ export class ProductService {
         optionGroupId: ID,
     ): Promise<Translated<Product>> {
         const product = await this.getProductWithOptionGroups(productId);
+        const optionGroup = product.optionGroups.find(g => idsAreEqual(g.id, optionGroupId));
+        if (!optionGroup) {
+            throw new EntityNotFoundError('ProductOptionGroup', optionGroupId);
+        }
+        if (product.variants.length) {
+            throw new UserInputError('error.cannot-remove-option-group-due-to-variants', {
+                code: optionGroup.code,
+                count: product.variants.length,
+            });
+        }
         product.optionGroups = product.optionGroups.filter(g => g.id !== optionGroupId);
 
         await this.connection.manager.save(product);
@@ -193,7 +203,7 @@ export class ProductService {
     private async getProductWithOptionGroups(productId: ID): Promise<Product> {
         const product = await this.connection
             .getRepository(Product)
-            .findOne(productId, { relations: ['optionGroups'], where: { deletedAt: null } });
+            .findOne(productId, { relations: ['optionGroups', 'variants', 'variants.options'], where: { deletedAt: null } });
         if (!product) {
             throw new EntityNotFoundError('Product', productId);
         }

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


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


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