From 6ac43d9a0591cc33d47f2734428588336461554c Mon Sep 17 00:00:00 2001 From: Michael Bromley Date: Mon, 19 Feb 2024 15:24:24 +0100 Subject: [PATCH] feat(core): Implement product duplicator Relates to #627 --- .../core/e2e/duplicate-entity.e2e-spec.ts | 258 ++++++++++++++++-- .../config/entity/entity-duplicators/index.ts | 4 +- .../entity-duplicators/product-duplicator.ts | 195 +++++++++++++ .../entity-duplicator.service.ts | 3 +- 4 files changed, 441 insertions(+), 19 deletions(-) create mode 100644 packages/core/src/config/entity/entity-duplicators/product-duplicator.ts diff --git a/packages/core/e2e/duplicate-entity.e2e-spec.ts b/packages/core/e2e/duplicate-entity.e2e-spec.ts index a662d7e597..10c906bf9a 100644 --- a/packages/core/e2e/duplicate-entity.e2e-spec.ts +++ b/packages/core/e2e/duplicate-entity.e2e-spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ import { Collection, CollectionService, @@ -26,7 +27,13 @@ import { Permission, RoleFragment, } from './graphql/generated-e2e-admin-types'; -import { CREATE_ADMINISTRATOR, CREATE_ROLE, GET_COLLECTIONS } from './graphql/shared-definitions'; +import { + CREATE_ADMINISTRATOR, + CREATE_ROLE, + GET_COLLECTIONS, + GET_PRODUCT_WITH_VARIANTS, + UPDATE_PRODUCT_VARIANTS, +} from './graphql/shared-definitions'; const customPermission = new PermissionDefinition({ name: 'custom', @@ -94,7 +101,7 @@ describe('Duplicating entities', () => { customPermissions: [customPermission], }, entityOptions: { - entityDuplicators: [/* ...defaultEntityDuplicators */ customCollectionDuplicator], + entityDuplicators: [...defaultEntityDuplicators, customCollectionDuplicator], }, }), ); @@ -156,21 +163,19 @@ describe('Duplicating entities', () => { GET_ENTITY_DUPLICATORS, ); - expect(entityDuplicators).toEqual([ - { - args: [ - { - defaultValue: false, - name: 'throwError', - type: 'boolean', - }, - ], - code: 'custom-collection-duplicator', - description: 'Custom Collection Duplicator', - forEntities: ['Collection'], - requiresPermission: ['custom'], - }, - ]); + expect(entityDuplicators.find(d => d.code === 'custom-collection-duplicator')).toEqual({ + args: [ + { + defaultValue: false, + name: 'throwError', + type: 'boolean', + }, + ], + code: 'custom-collection-duplicator', + description: 'Custom Collection Duplicator', + forEntities: ['Collection'], + requiresPermission: ['custom'], + }); }); it('cannot duplicate if lacking permissions', async () => { @@ -281,6 +286,225 @@ describe('Duplicating entities', () => { slug: 'plants-copy', }); }); + + describe('default entity duplicators', () => { + describe('Product duplicator', () => { + let originalProduct: NonNullable; + let originalFirstVariant: NonNullable< + Codegen.GetProductWithVariantsQuery['product'] + >['variants'][0]; + let newProduct1Id: string; + let newProduct2Id: string; + + beforeAll(async () => { + await adminClient.asSuperAdmin(); + + // Add asset and facet values to the first product variant + const { updateProductVariants } = await adminClient.query< + Codegen.UpdateProductVariantsMutation, + Codegen.UpdateProductVariantsMutationVariables + >(UPDATE_PRODUCT_VARIANTS, { + input: [ + { + id: 'T_1', + assetIds: ['T_1'], + featuredAssetId: 'T_1', + facetValueIds: ['T_1', 'T_2'], + }, + ], + }); + + const { product } = await adminClient.query< + Codegen.GetProductWithVariantsQuery, + Codegen.GetProductWithVariantsQueryVariables + >(GET_PRODUCT_WITH_VARIANTS, { + id: 'T_1', + }); + originalProduct = product!; + originalFirstVariant = product!.variants.find(v => v.id === 'T_1')!; + }); + + it('duplicate product without variants', async () => { + const { duplicateEntity } = await adminClient.query< + Codegen.DuplicateEntityMutation, + Codegen.DuplicateEntityMutationVariables + >(DUPLICATE_ENTITY, { + input: { + entityName: 'Product', + entityId: 'T_1', + duplicatorInput: { + code: 'product-duplicator', + arguments: [ + { + name: 'includeVariants', + value: 'false', + }, + ], + }, + }, + }); + + duplicateEntityGuard.assertSuccess(duplicateEntity); + + newProduct1Id = duplicateEntity.newEntityId; + + expect(newProduct1Id).toBe('T_2'); + }); + + it('new product has no variants', async () => { + const { product } = await adminClient.query< + Codegen.GetProductWithVariantsQuery, + Codegen.GetProductWithVariantsQueryVariables + >(GET_PRODUCT_WITH_VARIANTS, { + id: newProduct1Id, + }); + + expect(product?.variants.length).toBe(0); + }); + + it('is initially disabled', async () => { + const { product } = await adminClient.query< + Codegen.GetProductWithVariantsQuery, + Codegen.GetProductWithVariantsQueryVariables + >(GET_PRODUCT_WITH_VARIANTS, { + id: newProduct1Id, + }); + + expect(product?.enabled).toBe(false); + }); + + it('assets are duplicated', async () => { + const { product } = await adminClient.query< + Codegen.GetProductWithVariantsQuery, + Codegen.GetProductWithVariantsQueryVariables + >(GET_PRODUCT_WITH_VARIANTS, { + id: newProduct1Id, + }); + + expect(product?.featuredAsset).toEqual(originalProduct.featuredAsset); + expect(product?.assets.length).toBe(1); + expect(product?.assets).toEqual(originalProduct.assets); + }); + + it('facet values are duplicated', async () => { + const { product } = await adminClient.query< + Codegen.GetProductWithVariantsQuery, + Codegen.GetProductWithVariantsQueryVariables + >(GET_PRODUCT_WITH_VARIANTS, { + id: newProduct1Id, + }); + + expect(product?.facetValues).toEqual(originalProduct.facetValues); + expect(product?.facetValues.map(fv => fv.name).sort()).toEqual(['computers', 'electronics']); + }); + + it('duplicate product with variants', async () => { + const { duplicateEntity } = await adminClient.query< + Codegen.DuplicateEntityMutation, + Codegen.DuplicateEntityMutationVariables + >(DUPLICATE_ENTITY, { + input: { + entityName: 'Product', + entityId: 'T_1', + duplicatorInput: { + code: 'product-duplicator', + arguments: [ + { + name: 'includeVariants', + value: 'true', + }, + ], + }, + }, + }); + + duplicateEntityGuard.assertSuccess(duplicateEntity); + + newProduct2Id = duplicateEntity.newEntityId; + + expect(newProduct2Id).toBe('T_3'); + }); + + it('new product has variants', async () => { + const { product } = await adminClient.query< + Codegen.GetProductWithVariantsQuery, + Codegen.GetProductWithVariantsQueryVariables + >(GET_PRODUCT_WITH_VARIANTS, { + id: newProduct2Id, + }); + + expect(product?.variants.length).toBe(4); + expect(product?.variants.length).toBe(originalProduct.variants.length); + + expect(product?.variants.map(v => v.name).sort()).toEqual( + originalProduct.variants.map(v => v.name).sort(), + ); + }); + + it('variant SKUs are suffixed', async () => { + const { product } = await adminClient.query< + Codegen.GetProductWithVariantsQuery, + Codegen.GetProductWithVariantsQueryVariables + >(GET_PRODUCT_WITH_VARIANTS, { + id: newProduct2Id, + }); + + expect(product?.variants.map(v => v.sku).sort()).toEqual([ + 'L2201308-copy', + 'L2201316-copy', + 'L2201508-copy', + 'L2201516-copy', + ]); + }); + + it('variant assets are preserved', async () => { + const { product } = await adminClient.query< + Codegen.GetProductWithVariantsQuery, + Codegen.GetProductWithVariantsQueryVariables + >(GET_PRODUCT_WITH_VARIANTS, { + id: newProduct2Id, + }); + + expect(product?.variants.find(v => v.name === originalFirstVariant.name)?.assets).toEqual( + originalFirstVariant.assets, + ); + + expect( + product?.variants.find(v => v.name === originalFirstVariant.name)?.featuredAsset, + ).toEqual(originalFirstVariant.featuredAsset); + }); + + it('variant facet values are preserved', async () => { + const { product } = await adminClient.query< + Codegen.GetProductWithVariantsQuery, + Codegen.GetProductWithVariantsQueryVariables + >(GET_PRODUCT_WITH_VARIANTS, { + id: newProduct2Id, + }); + + expect( + product?.variants.find(v => v.name === originalFirstVariant.name)?.facetValues.length, + ).toBe(2); + + expect( + product?.variants.find(v => v.name === originalFirstVariant.name)?.facetValues, + ).toEqual(originalFirstVariant.facetValues); + }); + + it('variant stock levels are preserved', async () => { + const { product } = await adminClient.query< + Codegen.GetProductWithVariantsQuery, + Codegen.GetProductWithVariantsQueryVariables + >(GET_PRODUCT_WITH_VARIANTS, { + id: newProduct2Id, + }); + + expect(product?.variants.find(v => v.name === originalFirstVariant.name)?.stockOnHand).toBe( + 100, + ); + }); + }); + }); }); const GET_ENTITY_DUPLICATORS = gql` diff --git a/packages/core/src/config/entity/entity-duplicators/index.ts b/packages/core/src/config/entity/entity-duplicators/index.ts index 29c2bad5e7..31e6156939 100644 --- a/packages/core/src/config/entity/entity-duplicators/index.ts +++ b/packages/core/src/config/entity/entity-duplicators/index.ts @@ -1 +1,3 @@ -export const defaultEntityDuplicators = []; +import { productDuplicator } from './product-duplicator'; + +export const defaultEntityDuplicators = [productDuplicator]; diff --git a/packages/core/src/config/entity/entity-duplicators/product-duplicator.ts b/packages/core/src/config/entity/entity-duplicators/product-duplicator.ts new file mode 100644 index 0000000000..2865e641c2 --- /dev/null +++ b/packages/core/src/config/entity/entity-duplicators/product-duplicator.ts @@ -0,0 +1,195 @@ +import { + CreateProductInput, + CreateProductOptionInput, + CreateProductVariantInput, + LanguageCode, + Permission, + ProductTranslationInput, +} from '@vendure/common/lib/generated-types'; +import { IsNull } from 'typeorm'; + +import { Injector, InternalServerError } from '../../../common/index'; +import { TransactionalConnection } from '../../../connection/index'; +import { Product, ProductOptionGroup, ProductVariant } from '../../../entity/index'; +import { + ProductOptionGroupService, + ProductOptionService, + ProductService, + ProductVariantService, +} from '../../../service/index'; +import { EntityDuplicator } from '../entity-duplicator'; + +let connection: TransactionalConnection; +let productService: ProductService; +let productVariantService: ProductVariantService; +let productOptionGroupService: ProductOptionGroupService; +let productOptionService: ProductOptionService; + +/** + * @description + * Duplicates a Product and its associated ProductVariants. + */ +export const productDuplicator = new EntityDuplicator({ + code: 'product-duplicator', + description: [ + { + languageCode: LanguageCode.en, + value: 'Default duplicator for Products', + }, + ], + requiresPermission: [Permission.CreateProduct, Permission.CreateCatalog], + forEntities: ['Product'], + args: { + includeVariants: { + type: 'boolean', + defaultValue: true, + label: [{ languageCode: LanguageCode.en, value: 'Include variants' }], + }, + }, + init(injector: Injector) { + connection = injector.get(TransactionalConnection); + productService = injector.get(ProductService); + productVariantService = injector.get(ProductVariantService); + productOptionGroupService = injector.get(ProductOptionGroupService); + productOptionService = injector.get(ProductOptionService); + }, + async duplicate({ ctx, id, args }) { + const product = await connection.getEntityOrThrow(ctx, Product, id, { + relations: { + featuredAsset: true, + assets: true, + channels: true, + facetValues: { + facet: true, + }, + optionGroups: { + options: true, + }, + }, + }); + const translations: ProductTranslationInput[] = product.translations.map(translation => { + return { + name: translation.name + ' Copy', + slug: translation.slug + '-copy', + description: translation.description, + languageCode: translation.languageCode, + customFields: translation.customFields, + }; + }); + const productInput: CreateProductInput = { + featuredAssetId: product.featuredAsset.id, + enabled: false, + assetIds: product.assets.map(value => value.assetId), + facetValueIds: product.facetValues.map(value => value.id), + translations, + customFields: product.customFields, + }; + + const duplicatedProduct = await productService.create(ctx, productInput); + + if (args.includeVariants) { + const productVariants = await connection.getRepository(ctx, ProductVariant).find({ + where: { + productId: id, + deletedAt: IsNull(), + }, + relations: { + options: { + group: true, + }, + assets: true, + featuredAsset: true, + stockLevels: true, + facetValues: true, + }, + }); + if (product.optionGroups && product.optionGroups.length) { + for (const optionGroup of product.optionGroups) { + const newOptionGroup = await productOptionGroupService.create(ctx, { + code: optionGroup.code, + translations: optionGroup.translations.map(translation => { + return { + languageCode: translation.languageCode, + name: translation.name, + customFields: translation.customFields, + }; + }), + options: [], + }); + const options: CreateProductOptionInput[] = optionGroup.options.map(option => { + return { + code: option.code, + productOptionGroupId: newOptionGroup.id, + translations: option.translations.map(translation => { + return { + languageCode: translation.languageCode, + name: translation.name, + customFields: translation.customFields, + }; + }), + }; + }); + if (options && options.length) { + for (const option of options) { + const newOption = await productOptionService.create(ctx, newOptionGroup, option); + newOptionGroup.options.push(newOption); + } + } + await productService.addOptionGroupToProduct( + ctx, + duplicatedProduct.id, + newOptionGroup.id, + ); + } + } + const newOptionGroups = await connection.getRepository(ctx, ProductOptionGroup).find({ + where: { + product: { id: duplicatedProduct.id }, + }, + relations: { + options: true, + }, + }); + + const variantInput: CreateProductVariantInput[] = productVariants.map((variant, i) => { + const options = variant.options.map(existingOption => { + const newOption = newOptionGroups + .find(og => og.code === existingOption.group.code) + ?.options.find(o => o.code === existingOption.code); + if (!newOption) { + throw new InternalServerError( + `An error occurred when creating option ${existingOption.code}`, + ); + } + return newOption; + }); + return { + productId: duplicatedProduct.id, + price: variant.price, + sku: `${variant.sku}-copy`, + stockOnHand: 1, + featuredAssetId: variant.featuredAsset?.id, + useGlobalOutOfStockThreshold: variant.useGlobalOutOfStockThreshold, + trackInventory: variant.trackInventory, + assetIds: variant.assets.map(value => value.assetId), + translations: variant.translations.map(translation => { + return { + languageCode: translation.languageCode, + name: translation.name, + }; + }), + optionIds: options.map(option => option.id), + facetValueIds: variant.facetValues.map(value => value.id), + stockLevels: variant.stockLevels.map(stockLevel => ({ + stockLocationId: stockLevel.stockLocationId, + stockOnHand: stockLevel.stockOnHand, + })), + }; + }); + const duplicatedProductVariants = await productVariantService.create(ctx, variantInput); + duplicatedProduct.variants = duplicatedProductVariants; + } + + return duplicatedProduct; + }, +}); diff --git a/packages/core/src/service/helpers/entity-duplicator/entity-duplicator.service.ts b/packages/core/src/service/helpers/entity-duplicator/entity-duplicator.service.ts index 281fb43bd0..b04364551e 100644 --- a/packages/core/src/service/helpers/entity-duplicator/entity-duplicator.service.ts +++ b/packages/core/src/service/helpers/entity-duplicator/entity-duplicator.service.ts @@ -8,7 +8,7 @@ import { import { RequestContext } from '../../../api/index'; import { DuplicateEntityError } from '../../../common/index'; -import { ConfigService } from '../../../config/index'; +import { ConfigService, Logger } from '../../../config/index'; import { TransactionalConnection } from '../../../connection/index'; import { ConfigArgService } from '../config-arg/config-arg.service'; @@ -65,6 +65,7 @@ export class EntityDuplicatorService { return { newEntityId: newEntity.id }; } catch (e: any) { await this.connection.rollBackTransaction(innerCtx); + Logger.error(e.message, undefined, e.stack); return new DuplicateEntityError({ duplicationError: e.message ?? e.toString(), });