Răsfoiți Sursa

feat(core): Implement product duplicator

Relates to #627
Michael Bromley 1 an în urmă
părinte
comite
6ac43d9a05

+ 241 - 17
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<Codegen.GetProductWithVariantsQuery['product']>;
+            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`

+ 3 - 1
packages/core/src/config/entity/entity-duplicators/index.ts

@@ -1 +1,3 @@
-export const defaultEntityDuplicators = [];
+import { productDuplicator } from './product-duplicator';
+
+export const defaultEntityDuplicators = [productDuplicator];

+ 195 - 0
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;
+    },
+});

+ 2 - 1
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(),
                 });