فهرست منبع

feat(core): Implement channel mutations on StockLocation

Michael Bromley 2 سال پیش
والد
کامیت
a1f6018634

+ 30 - 0
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -271,6 +271,11 @@ export type AssignPromotionsToChannelInput = {
   promotionIds: Array<Scalars['ID']>;
 };
 
+export type AssignStockLocationsToChannelInput = {
+  channelId: Scalars['ID'];
+  stockLocationIds: Array<Scalars['ID']>;
+};
+
 export type AuthenticationInput = {
   native?: InputMaybe<NativeAuthInput>;
 };
@@ -2581,6 +2586,8 @@ export type Mutation = {
   assignPromotionsToChannel: Array<Promotion>;
   /** Assign a Role to an Administrator */
   assignRoleToAdministrator: Administrator;
+  /** Assigns StockLocations to the specified Channel */
+  assignStockLocationsToChannel: Array<StockLocation>;
   /** Authenticates the user using a named authentication strategy */
   authenticate: AuthenticationResult;
   cancelJob: Job;
@@ -2707,6 +2714,7 @@ export type Mutation = {
   /** Delete multiple ShippingMethods */
   deleteShippingMethods: Array<DeletionResponse>;
   deleteStockLocation: DeletionResponse;
+  deleteStockLocations: DeletionResponse;
   /** Delete an existing Tag */
   deleteTag: DeletionResponse;
   /** Deletes multiple TaxCategories */
@@ -2762,6 +2770,8 @@ export type Mutation = {
   removePromotionsFromChannel: Array<Promotion>;
   /** Remove all settled jobs in the given queues older than the given date. Returns the number of jobs deleted. */
   removeSettledJobs: Scalars['Int'];
+  /** Removes StockLocations from the specified Channel */
+  removeStockLocationsFromChannel: Array<StockLocation>;
   requestCompleted: Scalars['Int'];
   requestStarted: Scalars['Int'];
   runPendingSearchIndexUpdates: Success;
@@ -2940,6 +2950,11 @@ export type MutationAssignRoleToAdministratorArgs = {
 };
 
 
+export type MutationAssignStockLocationsToChannelArgs = {
+  input: AssignStockLocationsToChannelInput;
+};
+
+
 export type MutationAuthenticateArgs = {
   input: AuthenticationInput;
   rememberMe?: InputMaybe<Scalars['Boolean']>;
@@ -3283,6 +3298,11 @@ export type MutationDeleteStockLocationArgs = {
 };
 
 
+export type MutationDeleteStockLocationsArgs = {
+  input: Array<DeleteStockLocationInput>;
+};
+
+
 export type MutationDeleteTagArgs = {
   id: Scalars['ID'];
 };
@@ -3412,6 +3432,11 @@ export type MutationRemoveSettledJobsArgs = {
 };
 
 
+export type MutationRemoveStockLocationsFromChannelArgs = {
+  input: RemoveStockLocationsFromChannelInput;
+};
+
+
 export type MutationSetActiveChannelArgs = {
   channelId: Scalars['ID'];
 };
@@ -5389,6 +5414,11 @@ export type RemovePromotionsFromChannelInput = {
   promotionIds: Array<Scalars['ID']>;
 };
 
+export type RemoveStockLocationsFromChannelInput = {
+  channelId: Scalars['ID'];
+  stockLocationIds: Array<Scalars['ID']>;
+};
+
 export type Return = Node & StockMovement & {
   __typename?: 'Return';
   createdAt: Scalars['DateTime'];

+ 30 - 0
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts

@@ -259,6 +259,11 @@ export type AssignPromotionsToChannelInput = {
   promotionIds: Array<Scalars['ID']>;
 };
 
+export type AssignStockLocationsToChannelInput = {
+  channelId: Scalars['ID'];
+  stockLocationIds: Array<Scalars['ID']>;
+};
+
 export type AuthenticationInput = {
   native?: InputMaybe<NativeAuthInput>;
 };
@@ -2487,6 +2492,8 @@ export type Mutation = {
   assignPromotionsToChannel: Array<Promotion>;
   /** Assign a Role to an Administrator */
   assignRoleToAdministrator: Administrator;
+  /** Assigns StockLocations to the specified Channel */
+  assignStockLocationsToChannel: Array<StockLocation>;
   /** Authenticates the user using a named authentication strategy */
   authenticate: AuthenticationResult;
   cancelJob: Job;
@@ -2613,6 +2620,7 @@ export type Mutation = {
   /** Delete multiple ShippingMethods */
   deleteShippingMethods: Array<DeletionResponse>;
   deleteStockLocation: DeletionResponse;
+  deleteStockLocations: DeletionResponse;
   /** Delete an existing Tag */
   deleteTag: DeletionResponse;
   /** Deletes multiple TaxCategories */
@@ -2668,6 +2676,8 @@ export type Mutation = {
   removePromotionsFromChannel: Array<Promotion>;
   /** Remove all settled jobs in the given queues older than the given date. Returns the number of jobs deleted. */
   removeSettledJobs: Scalars['Int'];
+  /** Removes StockLocations from the specified Channel */
+  removeStockLocationsFromChannel: Array<StockLocation>;
   runPendingSearchIndexUpdates: Success;
   setCustomerForDraftOrder: SetCustomerForDraftOrderResult;
   /** Sets the billing address for a draft Order */
@@ -2834,6 +2844,11 @@ export type MutationAssignRoleToAdministratorArgs = {
 };
 
 
+export type MutationAssignStockLocationsToChannelArgs = {
+  input: AssignStockLocationsToChannelInput;
+};
+
+
 export type MutationAuthenticateArgs = {
   input: AuthenticationInput;
   rememberMe?: InputMaybe<Scalars['Boolean']>;
@@ -3177,6 +3192,11 @@ export type MutationDeleteStockLocationArgs = {
 };
 
 
+export type MutationDeleteStockLocationsArgs = {
+  input: Array<DeleteStockLocationInput>;
+};
+
+
 export type MutationDeleteTagArgs = {
   id: Scalars['ID'];
 };
@@ -3306,6 +3326,11 @@ export type MutationRemoveSettledJobsArgs = {
 };
 
 
+export type MutationRemoveStockLocationsFromChannelArgs = {
+  input: RemoveStockLocationsFromChannelInput;
+};
+
+
 export type MutationSetCustomerForDraftOrderArgs = {
   customerId?: InputMaybe<Scalars['ID']>;
   input?: InputMaybe<CreateCustomerInput>;
@@ -5175,6 +5200,11 @@ export type RemovePromotionsFromChannelInput = {
   promotionIds: Array<Scalars['ID']>;
 };
 
+export type RemoveStockLocationsFromChannelInput = {
+  channelId: Scalars['ID'];
+  stockLocationIds: Array<Scalars['ID']>;
+};
+
 export type Return = Node & StockMovement & {
   createdAt: Scalars['DateTime'];
   id: Scalars['ID'];

+ 30 - 0
packages/common/src/generated-types.ts

@@ -266,6 +266,11 @@ export type AssignPromotionsToChannelInput = {
   promotionIds: Array<Scalars['ID']>;
 };
 
+export type AssignStockLocationsToChannelInput = {
+  channelId: Scalars['ID'];
+  stockLocationIds: Array<Scalars['ID']>;
+};
+
 export type AuthenticationInput = {
   native?: InputMaybe<NativeAuthInput>;
 };
@@ -2569,6 +2574,8 @@ export type Mutation = {
   assignPromotionsToChannel: Array<Promotion>;
   /** Assign a Role to an Administrator */
   assignRoleToAdministrator: Administrator;
+  /** Assigns StockLocations to the specified Channel */
+  assignStockLocationsToChannel: Array<StockLocation>;
   /** Authenticates the user using a named authentication strategy */
   authenticate: AuthenticationResult;
   cancelJob: Job;
@@ -2695,6 +2702,7 @@ export type Mutation = {
   /** Delete multiple ShippingMethods */
   deleteShippingMethods: Array<DeletionResponse>;
   deleteStockLocation: DeletionResponse;
+  deleteStockLocations: DeletionResponse;
   /** Delete an existing Tag */
   deleteTag: DeletionResponse;
   /** Deletes multiple TaxCategories */
@@ -2750,6 +2758,8 @@ export type Mutation = {
   removePromotionsFromChannel: Array<Promotion>;
   /** Remove all settled jobs in the given queues older than the given date. Returns the number of jobs deleted. */
   removeSettledJobs: Scalars['Int'];
+  /** Removes StockLocations from the specified Channel */
+  removeStockLocationsFromChannel: Array<StockLocation>;
   runPendingSearchIndexUpdates: Success;
   setCustomerForDraftOrder: SetCustomerForDraftOrderResult;
   /** Sets the billing address for a draft Order */
@@ -2916,6 +2926,11 @@ export type MutationAssignRoleToAdministratorArgs = {
 };
 
 
+export type MutationAssignStockLocationsToChannelArgs = {
+  input: AssignStockLocationsToChannelInput;
+};
+
+
 export type MutationAuthenticateArgs = {
   input: AuthenticationInput;
   rememberMe?: InputMaybe<Scalars['Boolean']>;
@@ -3259,6 +3274,11 @@ export type MutationDeleteStockLocationArgs = {
 };
 
 
+export type MutationDeleteStockLocationsArgs = {
+  input: Array<DeleteStockLocationInput>;
+};
+
+
 export type MutationDeleteTagArgs = {
   id: Scalars['ID'];
 };
@@ -3388,6 +3408,11 @@ export type MutationRemoveSettledJobsArgs = {
 };
 
 
+export type MutationRemoveStockLocationsFromChannelArgs = {
+  input: RemoveStockLocationsFromChannelInput;
+};
+
+
 export type MutationSetCustomerForDraftOrderArgs = {
   customerId?: InputMaybe<Scalars['ID']>;
   input?: InputMaybe<CreateCustomerInput>;
@@ -5311,6 +5336,11 @@ export type RemovePromotionsFromChannelInput = {
   promotionIds: Array<Scalars['ID']>;
 };
 
+export type RemoveStockLocationsFromChannelInput = {
+  channelId: Scalars['ID'];
+  stockLocationIds: Array<Scalars['ID']>;
+};
+
 export type Return = Node & StockMovement & {
   __typename?: 'Return';
   createdAt: Scalars['DateTime'];

+ 1 - 11
packages/core/e2e/fixtures/test-plugins/multi-location-stock-plugin.ts

@@ -23,12 +23,6 @@ declare module '@vendure/core/dist/entity/custom-entity-fields' {
 }
 
 export class TestStockLocationStrategy extends DefaultStockLocationStrategy {
-    private connection: TransactionalConnection;
-
-    init(injector: Injector) {
-        this.connection = injector.get(TransactionalConnection);
-    }
-
     forAllocation(
         ctx: RequestContext,
         stockLocations: StockLocation[],
@@ -41,11 +35,7 @@ export class TestStockLocationStrategy extends DefaultStockLocationStrategy {
         return [{ location: selectedLocation ?? stockLocations[0], quantity }];
     }
 
-    getAvailableStock(
-        ctx: RequestContext,
-        productVariantId: ID,
-        stockLevels: StockLevel[],
-    ): AvailableStock | Promise<AvailableStock> {
+    getAvailableStock(ctx: RequestContext, productVariantId: ID, stockLevels: StockLevel[]): AvailableStock {
         const locationId = ctx.req?.query.fromLocation;
         const locationStock =
             locationId &&

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 93 - 0
packages/core/e2e/graphql/generated-e2e-admin-types.ts


+ 373 - 0
packages/core/e2e/stock-location.e2e-spec.ts

@@ -0,0 +1,373 @@
+import { mergeConfig } from '@vendure/core';
+import {
+    createErrorResultGuard,
+    createTestEnvironment,
+    E2E_DEFAULT_CHANNEL_TOKEN,
+    ErrorResultGuard,
+} from '@vendure/testing';
+import gql from 'graphql-tag';
+import path from 'path';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+import { initialData } from '../../../e2e-common/e2e-initial-data';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+
+import { testSuccessfulPaymentMethod } from './fixtures/test-payment-methods';
+import * as Codegen from './graphql/generated-e2e-admin-types';
+import {
+    AssignProductsToChannelDocument,
+    CurrencyCode,
+    DeletionResult,
+    LanguageCode,
+    SortOrder,
+    TestAssignStockLocationToChannelDocument,
+    TestCreateStockLocationDocument,
+    TestDeleteStockLocationDocument,
+    TestGetStockLevelsForVariantDocument,
+    TestGetStockLocationsListDocument,
+    TestRemoveStockLocationsFromChannelDocument,
+    TestSetStockLevelInLocationDocument,
+    TestUpdateStockLocationDocument,
+} from './graphql/generated-e2e-admin-types';
+import * as CodegenShop from './graphql/generated-e2e-shop-types';
+import { CREATE_CHANNEL } from './graphql/shared-definitions';
+
+describe('Stock location', () => {
+    const defaultStockLocationId = 'T_1';
+    let secondStockLocationId: string;
+
+    const { server, adminClient, shopClient } = createTestEnvironment(
+        mergeConfig(testConfig(), {
+            paymentOptions: {
+                paymentMethodHandlers: [testSuccessfulPaymentMethod],
+            },
+        }),
+    );
+
+    const orderGuard: ErrorResultGuard<
+        CodegenShop.TestOrderFragmentFragment | CodegenShop.UpdatedOrderFragment
+    > = createErrorResultGuard(input => !!input.lines);
+
+    beforeAll(async () => {
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-stock-control-multi.csv'),
+            customerCount: 3,
+        });
+        await adminClient.asSuperAdmin();
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it('createStockLocation', async () => {
+        const { createStockLocation } = await adminClient.query(TestCreateStockLocationDocument, {
+            input: {
+                name: 'Second location',
+                description: 'Second location description',
+            },
+        });
+
+        expect(createStockLocation.name).toBe('Second location');
+        expect(createStockLocation.description).toBe('Second location description');
+        secondStockLocationId = createStockLocation.id;
+    });
+
+    it('updateStockLocation', async () => {
+        const { updateStockLocation } = await adminClient.query(TestUpdateStockLocationDocument, {
+            input: {
+                id: secondStockLocationId,
+                name: 'Second location updated',
+                description: 'Second location description updated',
+            },
+        });
+
+        expect(updateStockLocation.name).toBe('Second location updated');
+        expect(updateStockLocation.description).toBe('Second location description updated');
+    });
+
+    it('get stock locations list', async () => {
+        const { stockLocations } = await adminClient.query(TestGetStockLocationsListDocument, {
+            options: {
+                sort: {
+                    id: SortOrder.ASC,
+                },
+            },
+        });
+
+        expect(stockLocations.items.length).toBe(2);
+        expect(stockLocations.items[0].name).toBe('Default Stock Location');
+        expect(stockLocations.items[1].name).toBe('Second location updated');
+    });
+
+    it('assign stock to second location', async () => {
+        const { updateProductVariants } = await adminClient.query(TestSetStockLevelInLocationDocument, {
+            input: {
+                id: 'T_1',
+                stockLevels: [
+                    {
+                        stockLocationId: secondStockLocationId,
+                        stockOnHand: 50,
+                    },
+                ],
+            },
+        });
+        expect(
+            updateProductVariants[0]?.stockLevels.find(sl => sl.stockLocationId === defaultStockLocationId),
+        ).toEqual({
+            stockOnHand: 100,
+            stockAllocated: 0,
+            stockLocationId: defaultStockLocationId,
+        });
+        expect(
+            updateProductVariants[0]?.stockLevels.find(sl => sl.stockLocationId === secondStockLocationId),
+        ).toEqual({
+            stockOnHand: 50,
+            stockAllocated: 0,
+            stockLocationId: secondStockLocationId,
+        });
+    });
+
+    it('delete second stock location and assign stock to default location', async () => {
+        const { deleteStockLocation } = await adminClient.query(TestDeleteStockLocationDocument, {
+            input: {
+                id: secondStockLocationId,
+                transferToLocationId: defaultStockLocationId,
+            },
+        });
+
+        expect(deleteStockLocation.result).toBe(DeletionResult.DELETED);
+
+        const { productVariant } = await adminClient.query(TestGetStockLevelsForVariantDocument, {
+            id: 'T_1',
+        });
+
+        expect(productVariant?.stockLevels.length).toBe(1);
+        expect(productVariant?.stockLevels[0]).toEqual({
+            stockOnHand: 150,
+            stockAllocated: 0,
+            stockLocationId: defaultStockLocationId,
+        });
+    });
+
+    describe('multi channel', () => {
+        const SECOND_CHANNEL_TOKEN = 'second_channel_token';
+        let channelStockLocationId: string;
+        let secondChannelId: string;
+        const channelGuard: ErrorResultGuard<Codegen.ChannelFragment> =
+            createErrorResultGuard<Codegen.ChannelFragment>(input => !!input.defaultLanguageCode);
+
+        beforeAll(async () => {
+            const { createStockLocation } = await adminClient.query(TestCreateStockLocationDocument, {
+                input: {
+                    name: 'Channel location',
+                },
+            });
+            channelStockLocationId = createStockLocation.id;
+            const { createChannel } = await adminClient.query<
+                Codegen.CreateChannelMutation,
+                Codegen.CreateChannelMutationVariables
+            >(CREATE_CHANNEL, {
+                input: {
+                    code: 'second-channel',
+                    token: SECOND_CHANNEL_TOKEN,
+                    defaultLanguageCode: LanguageCode.en,
+                    currencyCode: CurrencyCode.GBP,
+                    pricesIncludeTax: true,
+                    defaultShippingZoneId: 'T_1',
+                    defaultTaxZoneId: 'T_1',
+                },
+            });
+            channelGuard.assertSuccess(createChannel);
+            secondChannelId = createChannel.id;
+
+            await adminClient.query(AssignProductsToChannelDocument, {
+                input: {
+                    channelId: secondChannelId,
+                    productIds: ['T_1'],
+                },
+            });
+        });
+
+        it('stock location not visible in channel before being assigned', async () => {
+            adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            const { stockLocations } = await adminClient.query(TestGetStockLocationsListDocument);
+
+            expect(stockLocations.items.length).toBe(0);
+        });
+
+        it('assign stock location to channel', async () => {
+            adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+            const { assignStockLocationsToChannel } = await adminClient.query(
+                TestAssignStockLocationToChannelDocument,
+                {
+                    input: {
+                        stockLocationIds: [channelStockLocationId],
+                        channelId: secondChannelId,
+                    },
+                },
+            );
+            expect(assignStockLocationsToChannel.length).toBe(1);
+            expect(assignStockLocationsToChannel[0].name).toBe('Channel location');
+        });
+
+        it('stock location visible in channel once assigned', async () => {
+            adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            const { stockLocations } = await adminClient.query(TestGetStockLocationsListDocument);
+
+            expect(stockLocations.items.length).toBe(1);
+            expect(stockLocations.items[0].name).toBe('Channel location');
+        });
+
+        it('assign stock to location in channel', async () => {
+            adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            const { updateProductVariants } = await adminClient.query(TestSetStockLevelInLocationDocument, {
+                input: {
+                    id: 'T_1',
+                    stockLevels: [
+                        {
+                            stockLocationId: channelStockLocationId,
+                            stockOnHand: 10,
+                        },
+                    ],
+                },
+            });
+        });
+
+        it('assigned variant stock level visible in channel', async () => {
+            adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            const { productVariant } = await adminClient.query(TestGetStockLevelsForVariantDocument, {
+                id: 'T_1',
+            });
+
+            expect(productVariant?.stockLevels.length).toBe(1);
+            expect(productVariant?.stockLevels[0]).toEqual({
+                stockOnHand: 10,
+                stockAllocated: 0,
+                stockLocationId: channelStockLocationId,
+            });
+        });
+
+        it('remove stock location from channel', async () => {
+            adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+            const { removeStockLocationsFromChannel } = await adminClient.query(
+                TestRemoveStockLocationsFromChannelDocument,
+                {
+                    input: {
+                        stockLocationIds: [channelStockLocationId],
+                        channelId: secondChannelId,
+                    },
+                },
+            );
+
+            expect(removeStockLocationsFromChannel.length).toBe(1);
+            expect(removeStockLocationsFromChannel[0].name).toBe('Channel location');
+        });
+
+        it('variant stock level no longer visible once removed from channel', async () => {
+            adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            const { productVariant } = await adminClient.query(TestGetStockLevelsForVariantDocument, {
+                id: 'T_1',
+            });
+
+            expect(productVariant?.stockLevels.length).toBe(0);
+        });
+    });
+});
+
+const GET_STOCK_LOCATIONS_LIST = gql`
+    query TestGetStockLocationsList($options: StockLocationListOptions) {
+        stockLocations(options: $options) {
+            items {
+                id
+                name
+                description
+            }
+            totalItems
+        }
+    }
+`;
+
+const GET_STOCK_LOCATION = gql`
+    query TestGetStockLocation($id: ID!) {
+        stockLocation(id: $id) {
+            id
+            name
+            description
+        }
+    }
+`;
+
+const CREATE_STOCK_LOCATION = gql`
+    mutation TestCreateStockLocation($input: CreateStockLocationInput!) {
+        createStockLocation(input: $input) {
+            id
+            name
+            description
+        }
+    }
+`;
+
+const UPDATE_STOCK_LOCATION = gql`
+    mutation TestUpdateStockLocation($input: UpdateStockLocationInput!) {
+        updateStockLocation(input: $input) {
+            id
+            name
+            description
+        }
+    }
+`;
+
+const DELETE_STOCK_LOCATION = gql`
+    mutation TestDeleteStockLocation($input: DeleteStockLocationInput!) {
+        deleteStockLocation(input: $input) {
+            result
+            message
+        }
+    }
+`;
+
+const GET_STOCK_LEVELS_FOR_VARIANT = gql`
+    query TestGetStockLevelsForVariant($id: ID!) {
+        productVariant(id: $id) {
+            id
+            stockLevels {
+                stockOnHand
+                stockAllocated
+                stockLocationId
+            }
+        }
+    }
+`;
+
+const SET_STOCK_LEVEL_IN_LOCATION = gql`
+    mutation TestSetStockLevelInLocation($input: UpdateProductVariantInput!) {
+        updateProductVariants(input: [$input]) {
+            id
+            stockLevels {
+                stockOnHand
+                stockAllocated
+                stockLocationId
+            }
+        }
+    }
+`;
+
+const ASSIGN_STOCK_LOCATION_TO_CHANNEL = gql`
+    mutation TestAssignStockLocationToChannel($input: AssignStockLocationsToChannelInput!) {
+        assignStockLocationsToChannel(input: $input) {
+            id
+            name
+        }
+    }
+`;
+
+const REMOVE_STOCK_LOCATIONS_FROM_CHANNEL = gql`
+    mutation TestRemoveStockLocationsFromChannel($input: RemoveStockLocationsFromChannelInput!) {
+        removeStockLocationsFromChannel(input: $input) {
+            id
+            name
+        }
+    }
+`;

+ 22 - 1
packages/core/src/api/resolvers/admin/stock-location.resolver.ts

@@ -1,12 +1,13 @@
 import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 import {
+    MutationAssignStockLocationsToChannelArgs,
     MutationCreateStockLocationArgs,
     MutationDeleteStockLocationArgs,
+    MutationRemoveStockLocationsFromChannelArgs,
     MutationUpdateStockLocationArgs,
     Permission,
     QueryStockLocationArgs,
     QueryStockLocationsArgs,
-    StockLocationList,
 } from '@vendure/common/lib/generated-types';
 
 import { StockLocationService } from '../../../service/services/stock-location.service';
@@ -51,4 +52,24 @@ export class StockLocationResolver {
     deleteStockLocation(@Ctx() ctx: RequestContext, @Args() args: MutationDeleteStockLocationArgs) {
         return this.stockLocationService.delete(ctx, args.input);
     }
+
+    @Mutation()
+    @Transaction()
+    @Allow(Permission.CreateStockLocation)
+    assignStockLocationsToChannel(
+        @Ctx() ctx: RequestContext,
+        @Args() args: MutationAssignStockLocationsToChannelArgs,
+    ) {
+        return this.stockLocationService.assignStockLocationsToChannel(ctx, args.input);
+    }
+
+    @Mutation()
+    @Transaction()
+    @Allow(Permission.DeleteStockLocation)
+    removeStockLocationsFromChannel(
+        @Ctx() ctx: RequestContext,
+        @Args() args: MutationRemoveStockLocationsFromChannelArgs,
+    ) {
+        return this.stockLocationService.removeStockLocationsFromChannel(ctx, args.input);
+    }
 }

+ 17 - 0
packages/core/src/api/schema/admin-api/stock-location.api.graphql

@@ -7,6 +7,13 @@ type Mutation {
     createStockLocation(input: CreateStockLocationInput!): StockLocation!
     updateStockLocation(input: UpdateStockLocationInput!): StockLocation!
     deleteStockLocation(input: DeleteStockLocationInput!): DeletionResponse!
+    deleteStockLocations(input: [DeleteStockLocationInput!]!): DeletionResponse!
+
+    "Assigns StockLocations to the specified Channel"
+    assignStockLocationsToChannel(input: AssignStockLocationsToChannelInput!): [StockLocation!]!
+
+    "Removes StockLocations from the specified Channel"
+    removeStockLocationsFromChannel(input: RemoveStockLocationsFromChannelInput!): [StockLocation!]!
 }
 
 # Generated at runtime
@@ -32,3 +39,13 @@ input DeleteStockLocationInput {
     id: ID!
     transferToLocationId: ID
 }
+
+input AssignStockLocationsToChannelInput {
+    stockLocationIds: [ID!]!
+    channelId: ID!
+}
+
+input RemoveStockLocationsFromChannelInput {
+    stockLocationIds: [ID!]!
+    channelId: ID!
+}

+ 1 - 1
packages/core/src/config/catalog/default-stock-location-strategy.ts

@@ -19,7 +19,7 @@ import { AvailableStock, LocationWithQuantity, StockLocationStrategy } from './s
  * @since 2.0.0
  */
 export class DefaultStockLocationStrategy implements StockLocationStrategy {
-    private connection: TransactionalConnection;
+    protected connection: TransactionalConnection;
 
     init(injector: Injector) {
         this.connection = injector.get(TransactionalConnection);

+ 1 - 1
packages/core/src/entity/stock-movement/stock-movement.entity.ts

@@ -27,7 +27,7 @@ export abstract class StockMovement extends VendureEntity {
     productVariant: ProductVariant;
 
     @Index()
-    @ManyToOne(type => StockLocation)
+    @ManyToOne(type => StockLocation, { onDelete: 'CASCADE' })
     stockLocation: StockLocation;
 
     @EntityId()

+ 8 - 8
packages/core/src/service/services/stock-level.service.ts

@@ -50,14 +50,14 @@ export class StockLevelService {
     }
 
     async getStockLevelsForVariant(ctx: RequestContext, productVariantId: ID): Promise<StockLevel[]> {
-        return this.connection.getRepository(ctx, StockLevel).find({
-            where: {
-                productVariantId,
-            },
-            relations: {
-                stockLocation: true,
-            },
-        });
+        return this.connection
+            .getRepository(ctx, StockLevel)
+            .createQueryBuilder('stockLevel')
+            .leftJoinAndSelect('stockLevel.stockLocation', 'stockLocation')
+            .leftJoin('stockLocation.channels', 'channel')
+            .where('stockLevel.productVariantId = :productVariantId', { productVariantId })
+            .andWhere('channel.id = :channelId', { channelId: ctx.channelId })
+            .getMany();
     }
 
     /**

+ 113 - 6
packages/core/src/service/services/stock-location.service.ts

@@ -1,22 +1,28 @@
 import { Injectable } from '@nestjs/common';
 import {
+    AssignStockLocationsToChannelInput,
     CreateStockLocationInput,
     DeleteStockLocationInput,
+    DeletionResponse,
+    DeletionResult,
+    Permission,
+    RemoveStockLocationsFromChannelInput,
     UpdateStockLocationInput,
 } from '@vendure/common/lib/generated-types';
 import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
 
 import { RelationPaths, RequestContext } from '../../api/index';
 import { RequestContextCacheService } from '../../cache/index';
-import { ListQueryOptions } from '../../common/index';
+import { ForbiddenError, idsAreEqual, ListQueryOptions, UserInputError } from '../../common/index';
 import { ConfigService } from '../../config/index';
 import { TransactionalConnection } from '../../connection/index';
-import { Order, OrderLine } from '../../entity/index';
+import { OrderLine, StockLevel } from '../../entity/index';
 import { StockLocation } from '../../entity/stock-location/stock-location.entity';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { RequestContextService } from '../helpers/request-context/request-context.service';
 
 import { ChannelService } from './channel.service';
+import { RoleService } from './role.service';
 
 @Injectable()
 export class StockLocationService {
@@ -24,6 +30,7 @@ export class StockLocationService {
         private requestContextService: RequestContextService,
         private connection: TransactionalConnection,
         private channelService: ChannelService,
+        private roleService: RoleService,
         private listQueryBuilder: ListQueryBuilder,
         private configService: ConfigService,
         private requestContextCache: RequestContextCacheService,
@@ -61,7 +68,7 @@ export class StockLocationService {
         const stockLocation = await this.connection.getRepository(ctx, StockLocation).save(
             new StockLocation({
                 name: input.name,
-                description: input.description,
+                description: input.description ?? '',
             }),
         );
         await this.channelService.assignToCurrentChannel(stockLocation, ctx);
@@ -80,10 +87,109 @@ export class StockLocationService {
         return this.connection.getRepository(ctx, StockLocation).save(stockLocation);
     }
 
-    async delete(ctx: RequestContext, input: DeleteStockLocationInput): Promise<StockLocation> {
+    async delete(ctx: RequestContext, input: DeleteStockLocationInput): Promise<DeletionResponse> {
         const stockLocation = await this.connection.getEntityOrThrow(ctx, StockLocation, input.id);
-        await this.connection.getRepository(ctx, StockLocation).remove(stockLocation);
-        return stockLocation;
+        if (input.transferToLocationId) {
+            // This is inefficient, and it would be nice to be able to do this as a single
+            // SQL `update` statement with a nested `select` subquery, but TypeORM doesn't
+            // seem to have a good solution for that. If this proves a perf bottleneck, we
+            // can look at implementing raw SQL with a switch over the DB type.
+            const stockLevelsToTransfer = await this.connection
+                .getRepository(ctx, StockLevel)
+                .find({ where: { stockLocationId: stockLocation.id } });
+            for (const stockLevel of stockLevelsToTransfer) {
+                const existingStockLevel = await this.connection.getRepository(ctx, StockLevel).findOne({
+                    where: {
+                        stockLocationId: input.transferToLocationId,
+                        productVariantId: stockLevel.productVariantId,
+                    },
+                });
+                if (existingStockLevel) {
+                    existingStockLevel.stockOnHand += stockLevel.stockOnHand;
+                    existingStockLevel.stockAllocated += stockLevel.stockAllocated;
+                    await this.connection.getRepository(ctx, StockLevel).save(existingStockLevel);
+                } else {
+                    const newStockLevel = new StockLevel({
+                        productVariantId: stockLevel.productVariantId,
+                        stockLocationId: input.transferToLocationId,
+                        stockOnHand: stockLevel.stockOnHand,
+                        stockAllocated: stockLevel.stockAllocated,
+                    });
+                    await this.connection.getRepository(ctx, StockLevel).save(newStockLevel);
+                }
+                await this.connection.getRepository(ctx, StockLevel).remove(stockLevel);
+            }
+        }
+        try {
+            await this.connection.getRepository(ctx, StockLocation).remove(stockLocation);
+        } catch (e: any) {
+            return {
+                result: DeletionResult.NOT_DELETED,
+                message: e.message,
+            };
+        }
+        return {
+            result: DeletionResult.DELETED,
+        };
+    }
+
+    async assignStockLocationsToChannel(
+        ctx: RequestContext,
+        input: AssignStockLocationsToChannelInput,
+    ): Promise<StockLocation[]> {
+        const hasPermission = await this.roleService.userHasAnyPermissionsOnChannel(ctx, input.channelId, [
+            Permission.UpdateStockLocation,
+        ]);
+        if (!hasPermission) {
+            throw new ForbiddenError();
+        }
+        for (const stockLocationId of input.stockLocationIds) {
+            const stockLocation = await this.connection.findOneInChannel(
+                ctx,
+                StockLocation,
+                stockLocationId,
+                ctx.channelId,
+            );
+            await this.channelService.assignToChannels(ctx, StockLocation, stockLocationId, [
+                input.channelId,
+            ]);
+        }
+        return this.connection.findByIdsInChannel(
+            ctx,
+            StockLocation,
+            input.stockLocationIds,
+            ctx.channelId,
+            {},
+        );
+    }
+
+    async removeStockLocationsFromChannel(
+        ctx: RequestContext,
+        input: RemoveStockLocationsFromChannelInput,
+    ): Promise<StockLocation[]> {
+        const hasPermission = await this.roleService.userHasAnyPermissionsOnChannel(ctx, input.channelId, [
+            Permission.DeleteStockLocation,
+        ]);
+        if (!hasPermission) {
+            throw new ForbiddenError();
+        }
+        const defaultChannel = await this.channelService.getDefaultChannel(ctx);
+        if (idsAreEqual(input.channelId, defaultChannel.id)) {
+            throw new UserInputError('error.stock-locations-cannot-be-removed-from-default-channel');
+        }
+        for (const stockLocationId of input.stockLocationIds) {
+            const stockLocation = await this.connection.getEntityOrThrow(ctx, StockLocation, stockLocationId);
+            await this.channelService.removeFromChannels(ctx, StockLocation, stockLocationId, [
+                input.channelId,
+            ]);
+        }
+        return this.connection.findByIdsInChannel(
+            ctx,
+            StockLocation,
+            input.stockLocationIds,
+            ctx.channelId,
+            {},
+        );
     }
 
     getAllStockLocations(ctx: RequestContext) {
@@ -158,6 +264,7 @@ export class StockLocationService {
                     description: 'The default stock location',
                 }),
             );
+            defaultStockLocation.channels = [];
             stockLocations.push(defaultStockLocation);
             await this.connection.getRepository(ctx, StockLocation).save(defaultStockLocation);
         }

+ 30 - 0
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -259,6 +259,11 @@ export type AssignPromotionsToChannelInput = {
   promotionIds: Array<Scalars['ID']>;
 };
 
+export type AssignStockLocationsToChannelInput = {
+  channelId: Scalars['ID'];
+  stockLocationIds: Array<Scalars['ID']>;
+};
+
 export type AuthenticationInput = {
   native?: InputMaybe<NativeAuthInput>;
 };
@@ -2487,6 +2492,8 @@ export type Mutation = {
   assignPromotionsToChannel: Array<Promotion>;
   /** Assign a Role to an Administrator */
   assignRoleToAdministrator: Administrator;
+  /** Assigns StockLocations to the specified Channel */
+  assignStockLocationsToChannel: Array<StockLocation>;
   /** Authenticates the user using a named authentication strategy */
   authenticate: AuthenticationResult;
   cancelJob: Job;
@@ -2613,6 +2620,7 @@ export type Mutation = {
   /** Delete multiple ShippingMethods */
   deleteShippingMethods: Array<DeletionResponse>;
   deleteStockLocation: DeletionResponse;
+  deleteStockLocations: DeletionResponse;
   /** Delete an existing Tag */
   deleteTag: DeletionResponse;
   /** Deletes multiple TaxCategories */
@@ -2668,6 +2676,8 @@ export type Mutation = {
   removePromotionsFromChannel: Array<Promotion>;
   /** Remove all settled jobs in the given queues older than the given date. Returns the number of jobs deleted. */
   removeSettledJobs: Scalars['Int'];
+  /** Removes StockLocations from the specified Channel */
+  removeStockLocationsFromChannel: Array<StockLocation>;
   runPendingSearchIndexUpdates: Success;
   setCustomerForDraftOrder: SetCustomerForDraftOrderResult;
   /** Sets the billing address for a draft Order */
@@ -2834,6 +2844,11 @@ export type MutationAssignRoleToAdministratorArgs = {
 };
 
 
+export type MutationAssignStockLocationsToChannelArgs = {
+  input: AssignStockLocationsToChannelInput;
+};
+
+
 export type MutationAuthenticateArgs = {
   input: AuthenticationInput;
   rememberMe?: InputMaybe<Scalars['Boolean']>;
@@ -3177,6 +3192,11 @@ export type MutationDeleteStockLocationArgs = {
 };
 
 
+export type MutationDeleteStockLocationsArgs = {
+  input: Array<DeleteStockLocationInput>;
+};
+
+
 export type MutationDeleteTagArgs = {
   id: Scalars['ID'];
 };
@@ -3306,6 +3326,11 @@ export type MutationRemoveSettledJobsArgs = {
 };
 
 
+export type MutationRemoveStockLocationsFromChannelArgs = {
+  input: RemoveStockLocationsFromChannelInput;
+};
+
+
 export type MutationSetCustomerForDraftOrderArgs = {
   customerId?: InputMaybe<Scalars['ID']>;
   input?: InputMaybe<CreateCustomerInput>;
@@ -5175,6 +5200,11 @@ export type RemovePromotionsFromChannelInput = {
   promotionIds: Array<Scalars['ID']>;
 };
 
+export type RemoveStockLocationsFromChannelInput = {
+  channelId: Scalars['ID'];
+  stockLocationIds: Array<Scalars['ID']>;
+};
+
 export type Return = Node & StockMovement & {
   createdAt: Scalars['DateTime'];
   id: Scalars['ID'];

+ 30 - 0
packages/payments-plugin/e2e/graphql/generated-admin-types.ts

@@ -259,6 +259,11 @@ export type AssignPromotionsToChannelInput = {
   promotionIds: Array<Scalars['ID']>;
 };
 
+export type AssignStockLocationsToChannelInput = {
+  channelId: Scalars['ID'];
+  stockLocationIds: Array<Scalars['ID']>;
+};
+
 export type AuthenticationInput = {
   native?: InputMaybe<NativeAuthInput>;
 };
@@ -2487,6 +2492,8 @@ export type Mutation = {
   assignPromotionsToChannel: Array<Promotion>;
   /** Assign a Role to an Administrator */
   assignRoleToAdministrator: Administrator;
+  /** Assigns StockLocations to the specified Channel */
+  assignStockLocationsToChannel: Array<StockLocation>;
   /** Authenticates the user using a named authentication strategy */
   authenticate: AuthenticationResult;
   cancelJob: Job;
@@ -2613,6 +2620,7 @@ export type Mutation = {
   /** Delete multiple ShippingMethods */
   deleteShippingMethods: Array<DeletionResponse>;
   deleteStockLocation: DeletionResponse;
+  deleteStockLocations: DeletionResponse;
   /** Delete an existing Tag */
   deleteTag: DeletionResponse;
   /** Deletes multiple TaxCategories */
@@ -2668,6 +2676,8 @@ export type Mutation = {
   removePromotionsFromChannel: Array<Promotion>;
   /** Remove all settled jobs in the given queues older than the given date. Returns the number of jobs deleted. */
   removeSettledJobs: Scalars['Int'];
+  /** Removes StockLocations from the specified Channel */
+  removeStockLocationsFromChannel: Array<StockLocation>;
   runPendingSearchIndexUpdates: Success;
   setCustomerForDraftOrder: SetCustomerForDraftOrderResult;
   /** Sets the billing address for a draft Order */
@@ -2834,6 +2844,11 @@ export type MutationAssignRoleToAdministratorArgs = {
 };
 
 
+export type MutationAssignStockLocationsToChannelArgs = {
+  input: AssignStockLocationsToChannelInput;
+};
+
+
 export type MutationAuthenticateArgs = {
   input: AuthenticationInput;
   rememberMe?: InputMaybe<Scalars['Boolean']>;
@@ -3177,6 +3192,11 @@ export type MutationDeleteStockLocationArgs = {
 };
 
 
+export type MutationDeleteStockLocationsArgs = {
+  input: Array<DeleteStockLocationInput>;
+};
+
+
 export type MutationDeleteTagArgs = {
   id: Scalars['ID'];
 };
@@ -3306,6 +3326,11 @@ export type MutationRemoveSettledJobsArgs = {
 };
 
 
+export type MutationRemoveStockLocationsFromChannelArgs = {
+  input: RemoveStockLocationsFromChannelInput;
+};
+
+
 export type MutationSetCustomerForDraftOrderArgs = {
   customerId?: InputMaybe<Scalars['ID']>;
   input?: InputMaybe<CreateCustomerInput>;
@@ -5175,6 +5200,11 @@ export type RemovePromotionsFromChannelInput = {
   promotionIds: Array<Scalars['ID']>;
 };
 
+export type RemoveStockLocationsFromChannelInput = {
+  channelId: Scalars['ID'];
+  stockLocationIds: Array<Scalars['ID']>;
+};
+
 export type Return = Node & StockMovement & {
   createdAt: Scalars['DateTime'];
   id: Scalars['ID'];

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
schema-admin.json


برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است