From 26f9e9ac8585786675874d11c927224ec128055a Mon Sep 17 00:00:00 2001 From: Martijn Date: Tue, 28 Nov 2023 13:55:15 +0100 Subject: [PATCH 01/12] feat(picqer): expose funtion to pull stocklevels --- packages/vendure-plugin-picqer/package.json | 2 +- .../src/api/picqer.resolvers.ts | 27 +++++++++++++++++-- .../src/api/picqer.service.ts | 12 +++++++-- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/packages/vendure-plugin-picqer/package.json b/packages/vendure-plugin-picqer/package.json index bae95611..718904c3 100644 --- a/packages/vendure-plugin-picqer/package.json +++ b/packages/vendure-plugin-picqer/package.json @@ -1,6 +1,6 @@ { "name": "@pinelab/vendure-plugin-picqer", - "version": "2.2.4", + "version": "2.2.5", "description": "Vendure plugin syncing to orders and stock with Picqer", "icon": "truck", "author": "Martijn van de Brug ", diff --git a/packages/vendure-plugin-picqer/src/api/picqer.resolvers.ts b/packages/vendure-plugin-picqer/src/api/picqer.resolvers.ts index 7dad4553..601be688 100644 --- a/packages/vendure-plugin-picqer/src/api/picqer.resolvers.ts +++ b/packages/vendure-plugin-picqer/src/api/picqer.resolvers.ts @@ -3,10 +3,12 @@ import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; import { Allow, Ctx, + Logger, PermissionDefinition, RequestContext, } from '@vendure/core'; -import { PLUGIN_INIT_OPTIONS } from '../constants'; +import { all } from 'axios'; +import { loggerCtx, PLUGIN_INIT_OPTIONS } from '../constants'; import { PicqerOptions } from '../picqer.plugin'; import { PicqerConfig, @@ -30,7 +32,28 @@ export class PicqerResolver { @Mutation() @Allow(picqerPermission.Permission) async triggerPicqerFullSync(@Ctx() ctx: RequestContext): Promise { - return this.service.triggerFullSync(ctx); + let allSucceeded = true; + await this.service + .createStockLevelJobs(ctx) + .catch((e: Error | undefined) => { + Logger.error( + `Failed to create jobs to pull stock levels from Picqer: ${e?.message}`, + loggerCtx, + e?.stack + ); + allSucceeded = false; + }); + await this.service + .createPushProductsJob(ctx) + .catch((e: Error | undefined) => { + Logger.error( + `Failed to create jobs to push products to Picqer: ${e?.message}`, + loggerCtx, + e?.stack + ); + allSucceeded = false; + }); + return allSucceeded; } @Mutation() diff --git a/packages/vendure-plugin-picqer/src/api/picqer.service.ts b/packages/vendure-plugin-picqer/src/api/picqer.service.ts index eb2b8d65..9eb176ab 100644 --- a/packages/vendure-plugin-picqer/src/api/picqer.service.ts +++ b/packages/vendure-plugin-picqer/src/api/picqer.service.ts @@ -302,7 +302,10 @@ export class PicqerService implements OnApplicationBootstrap { Logger.info(`Successfully handled hook ${input.body.event}`, loggerCtx); } - async triggerFullSync(ctx: RequestContext): Promise { + /** + * Create jobs to push all Vendure variants as products to Picqer + */ + async createPushProductsJob(ctx: RequestContext): Promise { const variantIds: ID[] = []; let skip = 0; const take = 1000; @@ -334,6 +337,12 @@ export class PicqerService implements OnApplicationBootstrap { while (variantIds.length) { await this.addPushVariantsJob(ctx, variantIds.splice(0, batchSize)); } + } + + /** + * Create job to pull stock levels of all products from Picqer + */ + async createStockLevelJobs(ctx: RequestContext): Promise { await this.jobQueue.add( { action: 'pull-stock-levels', @@ -342,7 +351,6 @@ export class PicqerService implements OnApplicationBootstrap { { retries: 10 } ); Logger.info(`Added 'pull-stock-levels' job to queue`, loggerCtx); - return true; } /** From 758b97e1074a2537b73da18e7384b3bce775ef69 Mon Sep 17 00:00:00 2001 From: Martijn Date: Tue, 28 Nov 2023 13:56:21 +0100 Subject: [PATCH 02/12] feat(picqer): changelog --- packages/vendure-plugin-picqer/CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/vendure-plugin-picqer/CHANGELOG.md b/packages/vendure-plugin-picqer/CHANGELOG.md index 2320f041..49d789c9 100644 --- a/packages/vendure-plugin-picqer/CHANGELOG.md +++ b/packages/vendure-plugin-picqer/CHANGELOG.md @@ -1,4 +1,8 @@ -# 2.2.4 (2023-11-21) +# 2.2.5 (2023-11-28) + +- Expose `PicqerService.createStockLevelJobs(ctx)` to allow consumers to only trigger stock level updates + +- # 2.2.4 (2023-11-21) - Take Picqer allocated stock into account when setting stock on hand From 4327fd21ffa92fc20abd9203d285bc8078f9177e Mon Sep 17 00:00:00 2001 From: Martijn Date: Tue, 28 Nov 2023 14:23:14 +0100 Subject: [PATCH 03/12] feat(picqer): wip --- packages/vendure-plugin-picqer/CHANGELOG.md | 5 +- packages/vendure-plugin-picqer/README.md | 3 +- .../src/api/picqer.controller.ts | 47 +++++++++++++++++-- 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/packages/vendure-plugin-picqer/CHANGELOG.md b/packages/vendure-plugin-picqer/CHANGELOG.md index 49d789c9..418e3f71 100644 --- a/packages/vendure-plugin-picqer/CHANGELOG.md +++ b/packages/vendure-plugin-picqer/CHANGELOG.md @@ -1,6 +1,7 @@ -# 2.2.5 (2023-11-28) +# 2.3.0 (2023-11-28) -- Expose `PicqerService.createStockLevelJobs(ctx)` to allow consumers to only trigger stock level updates +- Expose endpoint to periodically pull stock levels +- Load - # 2.2.4 (2023-11-21) diff --git a/packages/vendure-plugin-picqer/README.md b/packages/vendure-plugin-picqer/README.md index 7c454816..8bac3e83 100644 --- a/packages/vendure-plugin-picqer/README.md +++ b/packages/vendure-plugin-picqer/README.md @@ -60,7 +60,8 @@ Start the server and set the fulfillment handler to `picqer: Fulfill with Picqer Stock levels are updated in Vendure on 1. Full sync via the Admin UI -2. Or, on incoming webhook from Picqer +2. On incoming webhook from Picqer +3. Or, on trigger of the GET endpoint `/picqer/pull-stock/`. You will need to pass your api key as auth header when you call this endpoint: `Authorization: "Bearer 1134090403msfksdl"` This plugin will mirror the stock locations from Picqer. Non-Picqer stock locations will automatically be deleted by the plugin, to keep stock in sync with Picqer. Vendure's internal allocated stock will be ignored, because this is handled by Picqer. diff --git a/packages/vendure-plugin-picqer/src/api/picqer.controller.ts b/packages/vendure-plugin-picqer/src/api/picqer.controller.ts index 090c9198..d9ea48da 100644 --- a/packages/vendure-plugin-picqer/src/api/picqer.controller.ts +++ b/packages/vendure-plugin-picqer/src/api/picqer.controller.ts @@ -1,14 +1,25 @@ -import { Body, Controller, Headers, Param, Post, Req } from '@nestjs/common'; -import { Logger } from '@vendure/core'; +import { + Controller, + ForbiddenException, + Headers, + Param, + Post, + Req, + BadRequestException, +} from '@nestjs/common'; +import { ChannelService, Logger, RequestContext } from '@vendure/core'; import { Request } from 'express'; +import util from 'util'; import { loggerCtx } from '../constants'; import { PicqerService } from './picqer.service'; import { IncomingWebhook } from './types'; -import util from 'util'; @Controller('picqer') export class PicqerController { - constructor(private picqerService: PicqerService) {} + constructor( + private picqerService: PicqerService, + private channelService: ChannelService + ) {} @Post('hooks/:channelToken') async webhook( @@ -39,4 +50,32 @@ export class PicqerController { throw e; } } + + @Post('pull-stock/:channelToken') + async pullStockLevels( + @Headers('Authorization') authHeader: string, + @Param('channelToken') channelToken: string + ): Promise { + if (!authHeader) { + throw new ForbiddenException('No bearer token provided'); + } + const channel = await this.channelService.getChannelFromToken(channelToken); + if (!channel) { + throw new BadRequestException( + `No channel found for token ${channelToken}` + ); + } + const apiKey = authHeader.replace('Bearer ', '').replace('bearer ', ''); + const ctx = new RequestContext({ + apiType: 'admin', + isAuthorized: true, + authorizedAsOwnerOnly: false, + channel, + }); + const picqerConfig = await this.picqerService.getConfig(ctx); + if (picqerConfig?.apiKey !== apiKey) { + throw new ForbiddenException('Invalid bearer token'); + } + await this.picqerService.createStockLevelJobs(ctx); + } } From c60d09b230dc22f3d1c9a0f2997655ba3f733f18 Mon Sep 17 00:00:00 2001 From: Martijn Date: Tue, 28 Nov 2023 14:39:22 +0100 Subject: [PATCH 04/12] feat(picqer): readme --- packages/vendure-plugin-picqer/README.md | 27 +++++++++++++------ .../src/api/picqer.controller.ts | 2 +- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/vendure-plugin-picqer/README.md b/packages/vendure-plugin-picqer/README.md index 8bac3e83..9eaf4d10 100644 --- a/packages/vendure-plugin-picqer/README.md +++ b/packages/vendure-plugin-picqer/README.md @@ -61,23 +61,34 @@ Stock levels are updated in Vendure on 1. Full sync via the Admin UI 2. On incoming webhook from Picqer -3. Or, on trigger of the GET endpoint `/picqer/pull-stock/`. You will need to pass your api key as auth header when you call this endpoint: `Authorization: "Bearer 1134090403msfksdl"` +3. Or, on trigger of the GET endpoint `/picqer/pull-stock-levels/`. This plugin will mirror the stock locations from Picqer. Non-Picqer stock locations will automatically be deleted by the plugin, to keep stock in sync with Picqer. Vendure's internal allocated stock will be ignored, because this is handled by Picqer. You can use a custom [StockLocationStrategy](https://github.com/vendure-ecommerce/vendure/blob/major/packages/core/src/config/catalog/default-stock-location-strategy.ts) to control how available stock is calculated based on multiple locations. -## Orders +### Periodical stock level sync + +You can call the endpoint `/picqer/pull-stock-levels/`, with your Picqer API key as bearer token, to trigger a full stock level sync. This will pull stock levels from Picqer, and update them in Picqer. + +``` +curl -H "Authorization: Bearer abcde-your-apikey" `http://localhost:3000/picqer/pull-stock-levels/your-channel-token` +``` + +### Custom fulfillment process -1. Orders are pushed to Picqer with status `processing` when an order is placed in Vendure. The Vendure order will remain in `Payment Settled` and no fulfillments are created. -2. Products are fulfilled in Vendure based on the products in the incoming `picklist.closed` events from Picqer. This can result in the order being `Shipped` or `PartiallyShipped` -3. Currently, when the order is `Shipped` it will automatically transition to `Delivered`, because we do not receive delivery events from Picqer. +This plugin installs a custom fulfillment process in your Vendure instance, because Picqer will be responsible for fulfilling and thus for allocating/releasing stock. The custom fulfillment process makes sure an order is always fulfillable, stock synchronization with Picqer handles the stock levels. -### Order flow: +![!image](https://www.plantuml.com/plantuml/png/VOt1IeP054RtynJV0rIeAn4C9OXs2L7xmRdIq7McPkuiUlkqKVW5SNUvd7E-BeeEacPMJsp92UuVyK7Ef40DUcCW7XMiq1pNqmT3GMt0WVtK4MM1A7xyWf-oSXOTz2-qCuWamdHHx9dzg8Ns_IR7NztBehTbSGUz4QQjJWlFYIVBd3UkzS6EFnGEzjkA8tsR1S4KYFuVRVs0z_opReUXuw5UtyOBrQtKp4hz0G00) -![Order flow](https://www.plantuml.com/plantuml/png/RSvD2W8n30NWVKyHkjS3p49c8MuT4DmFxLCBwOzfwlcbM45XTW_oyYLprLMqHJPN9Dy4j3lG4jmJGXCjhJueYuTGJYCKNXqYalvkVED4fyQtmFmfRw8NA6acMoGxr1hItPen_9FENQXxbsDXAFpclQwDnxfv18SN1DwQkLSYlm40) +- Without incoming stock from Picqer, items would be allocated indefinitely. Picqer has to tell Vendure what the stock levels of items are. + +## Orders -[edit](https://www.plantuml.com/plantuml/uml/bOwn2i9038RtFaNef8E27Jj81n-W8BWVTr4FqqjDSe9lxnLQK73GBI7_z_tfr9nO7gWwOGfP43PxwAE_eq0BVTOhi8IoS9g7aPp70PF1ge5HE6HlklwA7z706EgIygWQqwMkvcE9BKGx0JUAQbjFh1ZWpBAOORUOFv6Ydl-P2ded5XtH4mv8yO62uV-cvfUcDtytHGPw0G00) +1. Orders are pushed to Picqer with status `processing` when an order is placed in Vendure. +2. The order is immediately fulfilled on order placement. +3. On incoming `order.completed` event from Picqer, the order is transitioned to `Shipped`. +4. There currently is no way of telling when an order is `Deliverd` based on Picqer events, so we automatically transition to `Delivered`. ## Caveats diff --git a/packages/vendure-plugin-picqer/src/api/picqer.controller.ts b/packages/vendure-plugin-picqer/src/api/picqer.controller.ts index d9ea48da..0b1685fa 100644 --- a/packages/vendure-plugin-picqer/src/api/picqer.controller.ts +++ b/packages/vendure-plugin-picqer/src/api/picqer.controller.ts @@ -51,7 +51,7 @@ export class PicqerController { } } - @Post('pull-stock/:channelToken') + @Post('pull-stock-levels/:channelToken') async pullStockLevels( @Headers('Authorization') authHeader: string, @Param('channelToken') channelToken: string From 752b9b7635aa128d2b14ff590de74d01d4db10e7 Mon Sep 17 00:00:00 2001 From: Martijn Date: Tue, 28 Nov 2023 14:42:15 +0100 Subject: [PATCH 05/12] feat(picqer): stock sync force --- .../vendure-plugin-picqer/src/api/picqer.service.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/vendure-plugin-picqer/src/api/picqer.service.ts b/packages/vendure-plugin-picqer/src/api/picqer.service.ts index 9eb176ab..06312d0c 100644 --- a/packages/vendure-plugin-picqer/src/api/picqer.service.ts +++ b/packages/vendure-plugin-picqer/src/api/picqer.service.ts @@ -667,13 +667,11 @@ export class PicqerService implements OnApplicationBootstrap { const allocated = picqerStock.reservedallocations ?? 0; const newStockOnHand = allocated + picqerStock.freestock; const delta = newStockOnHand - stockOnHand; - const res = await this.connection - .getRepository(ctx, StockLevel) - .save({ - id: stockLevelId, - stockOnHand: newStockOnHand, - stockAllocated: allocated, - }); + await this.connection.getRepository(ctx, StockLevel).save({ + id: stockLevelId, + stockOnHand: picqerStock.freestock, + stockAllocated: 0, // Reset allocations based on Picqer events, because of our custom fulfilmentproces + }); // Add stock adjustment stockAdjustments.push( new StockAdjustment({ From 57419bcb48412b339d934bb22ee678bf3d42e7b8 Mon Sep 17 00:00:00 2001 From: Martijn Date: Tue, 28 Nov 2023 14:47:52 +0100 Subject: [PATCH 06/12] feat(picqer): e2e tests --- .../vendure-plugin-picqer/test/picqer.spec.ts | 55 ++++++++++++------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/packages/vendure-plugin-picqer/test/picqer.spec.ts b/packages/vendure-plugin-picqer/test/picqer.spec.ts index 68d29f29..fa567780 100644 --- a/packages/vendure-plugin-picqer/test/picqer.spec.ts +++ b/packages/vendure-plugin-picqer/test/picqer.spec.ts @@ -1,29 +1,22 @@ +import { DefaultLogger, LogLevel, mergeConfig, Order } from '@vendure/core'; import { - DefaultLogger, - ID, - LogLevel, - mergeConfig, - OrderService, - RequestContext, - RequestContextService, -} from '@vendure/core'; -import { + createTestEnvironment, E2E_DEFAULT_CHANNEL_TOKEN, + registerInitializer, SimpleGraphQLClient, SqljsInitializer, - createTestEnvironment, - registerInitializer, testConfig, } from '@vendure/testing'; import { TestServer } from '@vendure/testing/lib/test-server'; import nock from 'nock'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; import { addShippingMethod, getAllVariants, getOrder, - updateProduct, updateVariants, } from '../../test/src/admin-utils'; +import getFilesInAdminUiFolder from '../../test/src/compile-admin-ui.util'; import { GetVariantsQuery, GlobalFlag, @@ -31,16 +24,10 @@ import { import { initialData } from '../../test/src/initial-data'; import { createSettledOrder } from '../../test/src/shop-utils'; import { testPaymentMethod } from '../../test/src/test-payment-method'; -import { PicqerPlugin, PicqerService } from '../src'; -import { VatGroup, IncomingOrderStatusWebhook } from '../src'; +import { IncomingOrderStatusWebhook, PicqerPlugin, VatGroup } from '../src'; +import { picqerHandler } from '../src/api/picqer.handler'; import { FULL_SYNC, GET_CONFIG, UPSERT_CONFIG } from '../src/ui/queries'; import { createSignature } from './test-helpers'; -import { Order } from '@vendure/core'; -import { picqerHandler } from '../src/api/picqer.handler'; -import { describe, afterEach, beforeAll, it, expect, afterAll } from 'vitest'; -import getFilesInAdminUiFolder from '../../test/src/compile-admin-ui.util'; -import { ad } from 'vitest/dist/types-e3c9754d'; -import { Orders } from '../../test/dist/generated/admin-graphql'; let server: TestServer; let adminClient: SimpleGraphQLClient; @@ -469,6 +456,34 @@ describe('Product synchronization', function () { }); }); +describe('Periodical stock updates', function () { + it('Throws forbidden for invalid api key', async () => { + const res = await adminClient.fetch( + `http://localhost:3050/picqer/hooks/${E2E_DEFAULT_CHANNEL_TOKEN}`, + { + method: 'GET', + headers: { + Authorization: 'Bearer this is not right', + }, + } + ); + expect(res.status).toBe(403); + }); + + it('Creates full sync jobs on calling of endpoint', async () => { + const res = await adminClient.fetch( + `http://localhost:3050/picqer/pull-stock-levels/${E2E_DEFAULT_CHANNEL_TOKEN}`, + { + method: 'GET', + headers: { + Authorization: 'Bearer test-api-key', + }, + } + ); + expect(res.status).toBe(201); + }); +}); + describe('Order modification', function () { it('Update stock again', async () => { const variants = await updateVariants(adminClient, [ From 6e32b5eeabc8bd8457233ebf4e424b09241dd960 Mon Sep 17 00:00:00 2001 From: Martijn Date: Tue, 28 Nov 2023 20:05:19 +0100 Subject: [PATCH 07/12] feat(picqer): readme --- packages/vendure-plugin-picqer/README.md | 6 +- .../src/api/picqer.controller.ts | 3 +- .../vendure-plugin-picqer/test/picqer.spec.ts | 72 +++++++++---------- 3 files changed, 41 insertions(+), 40 deletions(-) diff --git a/packages/vendure-plugin-picqer/README.md b/packages/vendure-plugin-picqer/README.md index 9eaf4d10..864da4aa 100644 --- a/packages/vendure-plugin-picqer/README.md +++ b/packages/vendure-plugin-picqer/README.md @@ -75,11 +75,11 @@ You can call the endpoint `/picqer/pull-stock-levels/`, with your curl -H "Authorization: Bearer abcde-your-apikey" `http://localhost:3000/picqer/pull-stock-levels/your-channel-token` ``` -### Custom fulfillment process +### Order process override -This plugin installs a custom fulfillment process in your Vendure instance, because Picqer will be responsible for fulfilling and thus for allocating/releasing stock. The custom fulfillment process makes sure an order is always fulfillable, stock synchronization with Picqer handles the stock levels. +This plugin installs the default order process with `checkFulfillmentStates: false`, so that orders can be transitioned to Shipped and Delivered without the need of fulfilment. Fulfilment is the responsibility of Picqer, so we wont handle that in Vendure when using this plugin. -![!image](https://www.plantuml.com/plantuml/png/VOt1IeP054RtynJV0rIeAn4C9OXs2L7xmRdIq7McPkuiUlkqKVW5SNUvd7E-BeeEacPMJsp92UuVyK7Ef40DUcCW7XMiq1pNqmT3GMt0WVtK4MM1A7xyWf-oSXOTz2-qCuWamdHHx9dzg8Ns_IR7NztBehTbSGUz4QQjJWlFYIVBd3UkzS6EFnGEzjkA8tsR1S4KYFuVRVs0z_opReUXuw5UtyOBrQtKp4hz0G00) +![!image](https://www.plantuml.com/plantuml/png/VOv1IyD048Nl-HNl1rH9Uog1I8iNRnQYtfVCn0nkPkFk1F7VIvgjfb2yBM_VVEyx97FHfi4NZrvO3NSFU6EbANA58n4iO0Sn7jBy394u5hbmrUrTmhP4ij1-87JBoIteoNt3AI6ncUT_Y4VlG-kCB_lL0d_M9wTKRyiDN6vGlLiJJj9-SgpGiDB2XuMSuaki3vEXctmdVc2r8l-ijvjv2TD8ytuNcSz1lR_7wvA9NifmwKfil_OgRy5VejCa9a7_x9fUnf5fy-lNHdOc-fv5pwQfECoCmVy0) - Without incoming stock from Picqer, items would be allocated indefinitely. Picqer has to tell Vendure what the stock levels of items are. diff --git a/packages/vendure-plugin-picqer/src/api/picqer.controller.ts b/packages/vendure-plugin-picqer/src/api/picqer.controller.ts index 0b1685fa..76f421e1 100644 --- a/packages/vendure-plugin-picqer/src/api/picqer.controller.ts +++ b/packages/vendure-plugin-picqer/src/api/picqer.controller.ts @@ -6,6 +6,7 @@ import { Post, Req, BadRequestException, + Get, } from '@nestjs/common'; import { ChannelService, Logger, RequestContext } from '@vendure/core'; import { Request } from 'express'; @@ -51,7 +52,7 @@ export class PicqerController { } } - @Post('pull-stock-levels/:channelToken') + @Get('pull-stock-levels/:channelToken') async pullStockLevels( @Headers('Authorization') authHeader: string, @Param('channelToken') channelToken: string diff --git a/packages/vendure-plugin-picqer/test/picqer.spec.ts b/packages/vendure-plugin-picqer/test/picqer.spec.ts index fa567780..9cb78dc5 100644 --- a/packages/vendure-plugin-picqer/test/picqer.spec.ts +++ b/packages/vendure-plugin-picqer/test/picqer.spec.ts @@ -456,34 +456,6 @@ describe('Product synchronization', function () { }); }); -describe('Periodical stock updates', function () { - it('Throws forbidden for invalid api key', async () => { - const res = await adminClient.fetch( - `http://localhost:3050/picqer/hooks/${E2E_DEFAULT_CHANNEL_TOKEN}`, - { - method: 'GET', - headers: { - Authorization: 'Bearer this is not right', - }, - } - ); - expect(res.status).toBe(403); - }); - - it('Creates full sync jobs on calling of endpoint', async () => { - const res = await adminClient.fetch( - `http://localhost:3050/picqer/pull-stock-levels/${E2E_DEFAULT_CHANNEL_TOKEN}`, - { - method: 'GET', - headers: { - Authorization: 'Bearer test-api-key', - }, - } - ); - expect(res.status).toBe(201); - }); -}); - describe('Order modification', function () { it('Update stock again', async () => { const variants = await updateVariants(adminClient, [ @@ -498,12 +470,12 @@ describe('Order modification', function () { it('Should create settled order', async () => { // Shipping method 3 should be our created Picqer handler method - createdOrder = await createSettledOrder(shopClient, 3, true, [ + createdOrder = (await createSettledOrder(shopClient, 3, true, [ { id: 'T_1', quantity: 1 }, { id: 'T_2', quantity: 1 }, - ]); - expect(createdOrder.code).toBeDefined(); - expect(createdOrder.state).toBe('PaymentSettled'); + ])) as any; + expect(createdOrder?.code).toBeDefined(); + expect(createdOrder?.state).toBe('PaymentSettled'); }); it('Should update order when order line is removed in Picqer', async () => { @@ -544,13 +516,13 @@ describe('Order modification', function () { it('Should create another settled order', async () => { // Shipping method 3 should be our created Picqer handler method - createdOrder = await createSettledOrder(shopClient, 3, true, [ + createdOrder = (await createSettledOrder(shopClient, 3, true, [ { id: 'T_1', quantity: 3 }, { id: 'T_2', quantity: 1 }, - ]); + ])) as any; await new Promise((r) => setTimeout(r, 500)); // Wait for job queue to finish - expect(createdOrder.code).toBeDefined(); - expect(createdOrder.state).toBe('PaymentSettled'); + expect(createdOrder?.code).toBeDefined(); + expect(createdOrder?.state).toBe('PaymentSettled'); }); it('Should update quantity when quantity is adjusted in Picqer', async () => { @@ -593,6 +565,34 @@ describe('Order modification', function () { }); }); +describe('Periodical stock updates', function () { + it('Throws forbidden for invalid api key', async () => { + const res = await adminClient.fetch( + `http://localhost:3050/picqer/pull-stock-levels/${E2E_DEFAULT_CHANNEL_TOKEN}`, + { + method: 'GET', + headers: { + Authorization: 'Bearer this is not right', + }, + } + ); + expect(res.status).toBe(403); + }); + + it('Creates full sync jobs on calling of endpoint', async () => { + const res = await adminClient.fetch( + `http://localhost:3050/picqer/pull-stock-levels/${E2E_DEFAULT_CHANNEL_TOKEN}`, + { + method: 'GET', + headers: { + Authorization: 'Bearer test-api-key', + }, + } + ); + expect(res.status).toBe(200); + }); +}); + if (process.env.TEST_ADMIN_UI) { it('Should compile admin', async () => { const files = await getFilesInAdminUiFolder(__dirname, PicqerPlugin.ui); From 3d86215277009633044df1f5b51a704af0411149 Mon Sep 17 00:00:00 2001 From: Martijn Date: Wed, 29 Nov 2023 08:32:59 +0100 Subject: [PATCH 08/12] feat(picqer): skipping fulfilment all together --- packages/test/src/admin.graphql | 1 + packages/test/src/generated/admin-graphql.ts | 83 +++++++- packages/test/src/generated/shop-graphql.ts | 2 + packages/vendure-plugin-picqer/README.md | 2 +- .../src/api/picqer.service.ts | 188 ++---------------- .../src/picqer.plugin.ts | 4 + .../vendure-plugin-picqer/test/picqer.spec.ts | 120 +---------- 7 files changed, 113 insertions(+), 287 deletions(-) diff --git a/packages/test/src/admin.graphql b/packages/test/src/admin.graphql index 72a7b41d..0820ebd9 100644 --- a/packages/test/src/admin.graphql +++ b/packages/test/src/admin.graphql @@ -146,6 +146,7 @@ query GetVariants { outOfStockThreshold trackInventory stockAllocated + stockLevel } totalItems } diff --git a/packages/test/src/generated/admin-graphql.ts b/packages/test/src/generated/admin-graphql.ts index c25c0776..77e46a82 100644 --- a/packages/test/src/generated/admin-graphql.ts +++ b/packages/test/src/generated/admin-graphql.ts @@ -2477,6 +2477,36 @@ export type ManualPaymentStateError = ErrorResult & { message: Scalars['String']; }; +export enum MetricInterval { + Daily = 'Daily' +} + +export type MetricSummary = { + __typename?: 'MetricSummary'; + entries: Array; + interval: MetricInterval; + title: Scalars['String']; + type: MetricType; +}; + +export type MetricSummaryEntry = { + __typename?: 'MetricSummaryEntry'; + label: Scalars['String']; + value: Scalars['Float']; +}; + +export type MetricSummaryInput = { + interval: MetricInterval; + refresh?: InputMaybe; + types: Array; +}; + +export enum MetricType { + AverageOrderValue = 'AverageOrderValue', + OrderCount = 'OrderCount', + OrderTotal = 'OrderTotal' +} + export type MimeTypeError = ErrorResult & { __typename?: 'MimeTypeError'; errorCode: ErrorCode; @@ -2777,6 +2807,8 @@ export type Mutation = { transitionFulfillmentToState: TransitionFulfillmentToStateResult; transitionOrderToState?: Maybe; transitionPaymentToState: TransitionPaymentToStateResult; + /** Push all products to, and pull all stock levels from Picqer */ + triggerPicqerFullSync: Scalars['Boolean']; /** Update the active (currently logged-in) Administrator */ updateActiveAdministrator: Administrator; /** Update an existing Administrator */ @@ -2832,6 +2864,8 @@ export type Mutation = { updateTaxRate: TaxRate; /** Update an existing Zone */ updateZone: Zone; + /** Upsert Picqer config for the current channel */ + upsertPicqerConfig: PicqerConfig; }; @@ -3647,6 +3681,11 @@ export type MutationUpdateZoneArgs = { input: UpdateZoneInput; }; + +export type MutationUpsertPicqerConfigArgs = { + input: PicqerConfigInput; +}; + export type NativeAuthInput = { password: Scalars['String']; username: Scalars['String']; @@ -4267,6 +4306,8 @@ export enum Permission { DeleteZone = 'DeleteZone', /** Owner means the user owns this entity, e.g. a Customer's own Order */ Owner = 'Owner', + /** Allows setting Picqer config and triggering Picqer full sync */ + Picqer = 'Picqer', /** Public means any unauthenticated user may perform the operation */ Public = 'Public', /** Grants permission to read Administrator */ @@ -4370,6 +4411,23 @@ export type PermissionDefinition = { name: Scalars['String']; }; +export type PicqerConfig = { + __typename?: 'PicqerConfig'; + apiEndpoint?: Maybe; + apiKey?: Maybe; + enabled?: Maybe; + storefrontUrl?: Maybe; + supportEmail?: Maybe; +}; + +export type PicqerConfigInput = { + apiEndpoint?: InputMaybe; + apiKey?: InputMaybe; + enabled?: InputMaybe; + storefrontUrl?: InputMaybe; + supportEmail?: InputMaybe; +}; + export type PreviewCollectionVariantsInput = { filters: Array; inheritFilters: Scalars['Boolean']; @@ -4850,12 +4908,16 @@ export type Query = { facets: FacetList; fulfillmentHandlers: Array; globalSettings: GlobalSettings; + /** Test Picqer config against the Picqer API */ + isPicqerConfigValid: Scalars['Boolean']; job?: Maybe; jobBufferSize: Array; jobQueues: Array; jobs: JobList; jobsById: Array; me?: Maybe; + /** Get metrics for the given interval and metric types. */ + metricSummary: Array; order?: Maybe; orders: OrderList; paymentMethod?: Maybe; @@ -4863,6 +4925,7 @@ export type Query = { paymentMethodHandlers: Array; paymentMethods: PaymentMethodList; pendingSearchIndexUpdates: Scalars['Int']; + picqerConfig?: Maybe; /** Used for real-time previews of the contents of a Collection */ previewCollectionVariants: ProductVariantList; /** Get a Product either by id or slug. If neither id nor slug is specified, an error will result. */ @@ -4996,6 +5059,11 @@ export type QueryFacetsArgs = { }; +export type QueryIsPicqerConfigValidArgs = { + input: TestPicqerInput; +}; + + export type QueryJobArgs = { jobId: Scalars['ID']; }; @@ -5016,6 +5084,11 @@ export type QueryJobsByIdArgs = { }; +export type QueryMetricSummaryArgs = { + input?: InputMaybe; +}; + + export type QueryOrderArgs = { id: Scalars['ID']; }; @@ -5974,6 +6047,13 @@ export type TestEligibleShippingMethodsInput = { shippingAddress: CreateAddressInput; }; +export type TestPicqerInput = { + apiEndpoint: Scalars['String']; + apiKey: Scalars['String']; + storefrontUrl: Scalars['String']; + supportEmail: Scalars['String']; +}; + export type TestShippingMethodInput = { calculator: ConfigurableOperationInput; checker: ConfigurableOperationInput; @@ -6435,7 +6515,7 @@ export type UpdateProductMutation = { __typename?: 'Mutation', updateProduct: { export type GetVariantsQueryVariables = Exact<{ [key: string]: never; }>; -export type GetVariantsQuery = { __typename?: 'Query', productVariants: { __typename?: 'ProductVariantList', totalItems: number, items: Array<{ __typename?: 'ProductVariant', name: string, id: string, sku: string, stockOnHand: number, price: any, priceWithTax: any, outOfStockThreshold: number, trackInventory: GlobalFlag, stockAllocated: number }> } }; +export type GetVariantsQuery = { __typename?: 'Query', productVariants: { __typename?: 'ProductVariantList', totalItems: number, items: Array<{ __typename?: 'ProductVariant', name: string, id: string, sku: string, stockOnHand: number, price: any, priceWithTax: any, outOfStockThreshold: number, trackInventory: GlobalFlag, stockAllocated: number, stockLevel: string }> } }; export type CreatePromotionMutationVariables = Exact<{ input: CreatePromotionInput; @@ -6592,6 +6672,7 @@ export const GetVariants = gql` outOfStockThreshold trackInventory stockAllocated + stockLevel } totalItems } diff --git a/packages/test/src/generated/shop-graphql.ts b/packages/test/src/generated/shop-graphql.ts index 95b6de76..1bd81843 100644 --- a/packages/test/src/generated/shop-graphql.ts +++ b/packages/test/src/generated/shop-graphql.ts @@ -2423,6 +2423,8 @@ export enum Permission { DeleteZone = 'DeleteZone', /** Owner means the user owns this entity, e.g. a Customer's own Order */ Owner = 'Owner', + /** Allows setting Picqer config and triggering Picqer full sync */ + Picqer = 'Picqer', /** Public means any unauthenticated user may perform the operation */ Public = 'Public', /** Grants permission to read Administrator */ diff --git a/packages/vendure-plugin-picqer/README.md b/packages/vendure-plugin-picqer/README.md index 864da4aa..676953bb 100644 --- a/packages/vendure-plugin-picqer/README.md +++ b/packages/vendure-plugin-picqer/README.md @@ -77,7 +77,7 @@ curl -H "Authorization: Bearer abcde-your-apikey" `http://localhost:3000/picqer/ ### Order process override -This plugin installs the default order process with `checkFulfillmentStates: false`, so that orders can be transitioned to Shipped and Delivered without the need of fulfilment. Fulfilment is the responsibility of Picqer, so we wont handle that in Vendure when using this plugin. +This plugin installs the default order process with `checkFulfillmentStates: false` configured, so that orders can be transitioned to Shipped and Delivered without the need of fulfilment. Fulfilment is the responsibility of Picqer, so we wont handle that in Vendure when using this plugin. ![!image](https://www.plantuml.com/plantuml/png/VOv1IyD048Nl-HNl1rH9Uog1I8iNRnQYtfVCn0nkPkFk1F7VIvgjfb2yBM_VVEyx97FHfi4NZrvO3NSFU6EbANA58n4iO0Sn7jBy394u5hbmrUrTmhP4ij1-87JBoIteoNt3AI6ncUT_Y4VlG-kCB_lL0d_M9wTKRyiDN6vGlLiJJj9-SgpGiDB2XuMSuaki3vEXctmdVc2r8l-ijvjv2TD8ytuNcSz1lR_7wvA9NifmwKfil_OgRy5VejCa9a7_x9fUnf5fy-lNHdOc-fv5pwQfECoCmVy0) diff --git a/packages/vendure-plugin-picqer/src/api/picqer.service.ts b/packages/vendure-plugin-picqer/src/api/picqer.service.ts index 06312d0c..2568eb2f 100644 --- a/packages/vendure-plugin-picqer/src/api/picqer.service.ts +++ b/packages/vendure-plugin-picqer/src/api/picqer.service.ts @@ -23,6 +23,7 @@ import { Order, OrderPlacedEvent, OrderService, + OrderStateTransitionError, ProductService, ProductVariant, ProductVariantEvent, @@ -40,7 +41,6 @@ import { StockAdjustment } from '@vendure/core/dist/entity/stock-movement/stock- import { StockMovement } from '@vendure/core/dist/entity/stock-movement/stock-movement.entity'; import currency from 'currency.js'; import util from 'util'; -import { fulfillAll } from '../../../util/src/order-state-util'; import { loggerCtx, PLUGIN_INIT_OPTIONS } from '../constants'; import { PicqerOptions } from '../picqer.plugin'; import { @@ -360,7 +360,6 @@ export class PicqerService implements OnApplicationBootstrap { const order = await this.orderService.findOneByCode(ctx, data.reference, [ 'lines', 'lines.productVariant', - 'fulfillments', ]); if (!order) { Logger.warn( @@ -369,20 +368,6 @@ export class PicqerService implements OnApplicationBootstrap { ); return; } - if (data.status !== 'cancelled' && data.status !== 'completed') { - Logger.info( - `Not handling incoming status '${data.status}'. Skipping status update for order ${order.code}`, - loggerCtx - ); - } - if (data.status === 'cancelled' && order.state === 'Cancelled') { - // Do nothing, order already Cancelled - return; - } - if (data.status === 'completed' && order.state === 'Delivered') { - // Do nothing, order already Delivered - return; - } if (data.status === 'cancelled' && order.state !== 'Cancelled') { const result = await this.orderService.cancelOrder(ctx, { orderId: order.id, @@ -402,143 +387,32 @@ export class PicqerService implements OnApplicationBootstrap { Logger.info(`Cancelled order ${order.code}`, loggerCtx); return; } - if (data.status === 'completed' && order.state !== 'Delivered') { - // Remove any items that don't exist in Picqer order - await this.updateOrderBasedOnPicqer(ctx, order, data.products); - // Order should be transitioned to Shipped, then to Delivered - if (order.state !== 'Shipped') { - // Try to fulfill order first. This should have been done already, except for back orders - await this.safeFulfill(ctx, order, 'order.status_changed webhook'); - // If order isn't Shipped yet, mark all it's fulfillments as Shipped - for (const fulfillment of order.fulfillments) { - const result = await this.orderService.transitionFulfillmentToState( - ctx, - fulfillment.id, - 'Shipped' - ); - if ((result as FulfillmentStateTransitionError).errorCode) { - Logger.error( - `Failed to transition fulfilment (${fulfillment.id}) of order ${ - order.code - } to Shipped: ${ - (result as FulfillmentStateTransitionError).message - }`, - loggerCtx, - util.inspect(result) - ); - return; - } - } - const updatedOrder = await assertFound( - this.orderService.findOne(ctx, order.id) - ); - if (updatedOrder.state !== 'Shipped') { - Logger.error( - `Failed to transition order ${order.code} to Shipped, after marking all fulfillments as Shipped. This order should be manually transitioned to Shipped and Delivered`, - loggerCtx - ); - return; - } - Logger.info(`Order ${order.code} transitioned to Shipped`, loggerCtx); - } - // Move all fulfillments to Delivered - for (const fulfillment of order.fulfillments) { - const result = await this.orderService.transitionFulfillmentToState( - ctx, - fulfillment.id, - 'Delivered' - ); - if ((result as FulfillmentStateTransitionError).errorCode) { - Logger.error( - `Failed to transition fulfilment (${fulfillment.id}) of order ${ - order.code - } to Delivered: ${ - (result as FulfillmentStateTransitionError).message - }`, - loggerCtx, - util.inspect(result) - ); - return; - } - } - const updatedOrder = await assertFound( - this.orderService.findOne(ctx, order.id) + if ( + data.status === 'completed' && + order.state !== 'Delivered' && + order.state !== 'Cancelled' + ) { + // Order should be transitioned to Delivered + const result = await this.orderService.transitionToState( + ctx, + order.id, + 'Delivered' ); - if (updatedOrder.state !== 'Delivered') { + const errorResult = result as OrderStateTransitionError; + if (errorResult.errorCode) { Logger.error( - `Failed to transition order ${order.code} to Delivered. This order should be manually transitioned to Delivered`, - loggerCtx + `Failed to transition order ${order.code} to Delivered: ${errorResult.message}`, + loggerCtx, + util.inspect(errorResult) ); return; } Logger.info(`Order ${order.code} transitioned to Delivered`, loggerCtx); } - } - - /** - * Cancel any order lines that don't exist in the Picqer order. This can happen when items have been manually removed in Picqer - * - * We don't add items in Vendure, because that would require additional payment. - */ - async updateOrderBasedOnPicqer( - ctx: RequestContext, - order: Order, - picqerProducts: { productcode: string; amount: number }[] - ): Promise { - const orderLinesToCancel: OrderLineInput[] = []; - let message: string = ``; - order.lines.forEach((line) => { - const picqerOrderLine = picqerProducts.find( - (p) => p.productcode === line.productVariant.sku - ); - if (!picqerOrderLine) { - orderLinesToCancel.push({ - orderLineId: line.id, - quantity: line.quantity, - }); - message += `Product ${line.productVariant.sku} not found in Picqer, canceled corresponding order line.\n`; - return; - } - if (picqerOrderLine.amount < line.quantity) { - const diff = line.quantity - picqerOrderLine.amount; - orderLinesToCancel.push({ - orderLineId: line.id, - quantity: diff, - }); - message += `Product ${line.productVariant.sku} quantity changed to ${line.quantity} based on order in Picqer.\n`; - } - }); - if (!message) { - // No adjustments needed - return order; - } Logger.info( - `Updating order lines for order ${order.code} based on Picer order`, + `Not handling incoming status '${data.status}' because order ${order.code} is already '${order.state}'`, loggerCtx ); - const result = await this.orderService.cancelOrder(ctx, { - orderId: order.id, - cancelShipping: false, - lines: orderLinesToCancel, - }); - if ((result as ErrorResult).errorCode) { - Logger.error( - `Failed to update order lines or order ${ - order.code - }, based on Picqer order: ${ - (result as ErrorResult).message - }. Changed order lines: ${message}`, - loggerCtx, - util.inspect(result) - ); - return order; - } - this.orderService.addNoteToOrder(ctx, { - id: order.id, - note: message, - isPublic: false, - }); - return result as Order; } /** @@ -802,8 +676,6 @@ export class PicqerService implements OnApplicationBootstrap { ); return; } - // Fulfill order first - await this.safeFulfill(ctx, order, 'order placement'); // Push the order to Picqer await this.pushOrderToPicqer(ctx, order, client); } @@ -1237,32 +1109,6 @@ export class PicqerService implements OnApplicationBootstrap { }; } - /** - * Fulfill without throwing errors. Logs an error if fulfilment fails - */ - private async safeFulfill( - ctx: RequestContext, - order: Order, - logAction: string - ): Promise { - try { - const fulfillment = await fulfillAll(ctx, this.orderService, order, { - code: picqerHandler.code, - arguments: [], - }); - Logger.info( - `Created fulfillment (${fulfillment.id}) for order ${order.code} on '${logAction}'`, - loggerCtx - ); - } catch (e: any) { - Logger.error( - `Failed to fulfill order ${order.code} on '${logAction}': ${e?.message}. Transition this order manually to 'Delivered' after checking that it exists in Picqer.`, - loggerCtx, - util.inspect(e) - ); - } - } - /** * Combine street and housenumber to get a full readable address. * Returns undefined if address undefined diff --git a/packages/vendure-plugin-picqer/src/picqer.plugin.ts b/packages/vendure-plugin-picqer/src/picqer.plugin.ts index cb50dbc6..67759e99 100644 --- a/packages/vendure-plugin-picqer/src/picqer.plugin.ts +++ b/packages/vendure-plugin-picqer/src/picqer.plugin.ts @@ -1,4 +1,5 @@ import { + configureDefaultOrderProcess, Order, PluginCommonModule, ProductVariant, @@ -74,6 +75,9 @@ export interface PicqerOptions { }); config.authOptions.customPermissions.push(picqerPermission); config.shippingOptions.fulfillmentHandlers.push(picqerHandler); + config.orderOptions.process = [ + configureDefaultOrderProcess({ checkFulfillmentStates: false }), + ]; return config; }, compatibility: '^2.0.0', diff --git a/packages/vendure-plugin-picqer/test/picqer.spec.ts b/packages/vendure-plugin-picqer/test/picqer.spec.ts index 9cb78dc5..016bebc6 100644 --- a/packages/vendure-plugin-picqer/test/picqer.spec.ts +++ b/packages/vendure-plugin-picqer/test/picqer.spec.ts @@ -21,6 +21,7 @@ import { GetVariantsQuery, GlobalFlag, } from '../../test/src/generated/admin-graphql'; +import { AddPaymentToOrderMutation } from '../../test/src/generated/shop-graphql'; import { initialData } from '../../test/src/initial-data'; import { createSettledOrder } from '../../test/src/shop-utils'; import { testPaymentMethod } from '../../test/src/test-payment-method'; @@ -202,7 +203,7 @@ describe('Order placement', function () { }) .reply(200, { idordder: 'mockOrderId' }); // Shipping method 3 should be our created Picqer handler method - createdOrder = await createSettledOrder( + createdOrder = (await createSettledOrder( shopClient, 3, true, @@ -218,14 +219,14 @@ describe('Order placement', function () { countryCode: 'NL', }, } - ); + )) as any; await new Promise((r) => setTimeout(r, 500)); // Wait for job queue to finish const variant = (await getAllVariants(adminClient)).find( (v) => v.id === 'T_1' ); - expect(variant!.stockOnHand).toBe(97); - expect(variant!.stockAllocated).toBe(0); - expect(picqerOrderRequest.reference).toBe(createdOrder.code); + expect(variant!.stockOnHand).toBe(100); + expect(variant!.stockAllocated).toBe(3); + expect(picqerOrderRequest.reference).toBe(createdOrder?.code); expect(picqerOrderRequest.deliveryname).toBeDefined(); expect(picqerOrderRequest.deliverycontactname).toBeUndefined(); expect(picqerOrderRequest.deliveryaddress).toBeDefined(); @@ -456,115 +457,6 @@ describe('Product synchronization', function () { }); }); -describe('Order modification', function () { - it('Update stock again', async () => { - const variants = await updateVariants(adminClient, [ - { id: 'T_1', stockLevels: [{ stockLocationId: '2', stockOnHand: 10 }] }, - { id: 'T_2', stockLevels: [{ stockLocationId: '2', stockOnHand: 10 }] }, - ]); - expect(variants[0]?.stockOnHand).toBe(10); - expect(variants[1]?.stockOnHand).toBe(10); - }); - - let createdOrder: Order | undefined; - - it('Should create settled order', async () => { - // Shipping method 3 should be our created Picqer handler method - createdOrder = (await createSettledOrder(shopClient, 3, true, [ - { id: 'T_1', quantity: 1 }, - { id: 'T_2', quantity: 1 }, - ])) as any; - expect(createdOrder?.code).toBeDefined(); - expect(createdOrder?.state).toBe('PaymentSettled'); - }); - - it('Should update order when order line is removed in Picqer', async () => { - const mockIncomingWebhook = { - event: 'orders.status_changed', - data: { - reference: createdOrder?.code, - status: 'completed', - products: [ - { - productcode: 'L2201308', - amount: 1, - }, - // Variant T_2 is missing here - ], - }, - } as Partial; - await adminClient.fetch( - `http://localhost:3050/picqer/hooks/${E2E_DEFAULT_CHANNEL_TOKEN}`, - { - method: 'POST', - body: JSON.stringify(mockIncomingWebhook), - headers: { - 'X-Picqer-Signature': createSignature( - mockIncomingWebhook, - 'test-api-key' - ), - }, - } - ); - await new Promise((r) => setTimeout(r, 500)); // Wait for job queue to finish - const order = await getOrder(adminClient, createdOrder?.id as string); - expect(order?.lines[0].productVariant.sku).toBe('L2201308'); - expect(order?.lines[0].quantity).toBe(1); - expect(order?.lines[1].productVariant.sku).toBe('L2201508'); - expect(order?.lines[1].quantity).toBe(0); - }); - - it('Should create another settled order', async () => { - // Shipping method 3 should be our created Picqer handler method - createdOrder = (await createSettledOrder(shopClient, 3, true, [ - { id: 'T_1', quantity: 3 }, - { id: 'T_2', quantity: 1 }, - ])) as any; - await new Promise((r) => setTimeout(r, 500)); // Wait for job queue to finish - expect(createdOrder?.code).toBeDefined(); - expect(createdOrder?.state).toBe('PaymentSettled'); - }); - - it('Should update quantity when quantity is adjusted in Picqer', async () => { - const mockIncomingWebhook = { - event: 'orders.status_changed', - data: { - reference: createdOrder?.code, - status: 'completed', - products: [ - { - productcode: 'L2201308', - amount: 1, // Adjusted from 3 - }, - { - productcode: 'L2201508', - amount: 1, - }, - ], - }, - } as Partial; - await adminClient.fetch( - `http://localhost:3050/picqer/hooks/${E2E_DEFAULT_CHANNEL_TOKEN}`, - { - method: 'POST', - body: JSON.stringify(mockIncomingWebhook), - headers: { - 'X-Picqer-Signature': createSignature( - mockIncomingWebhook, - 'test-api-key' - ), - }, - } - ); - const order = await getOrder(adminClient, createdOrder?.id as string); - expect(order?.lines.length).toBe(2); - expect(order?.lines[0].productVariant.sku).toBe('L2201308'); - expect(order?.lines[0].quantity).toBe(1); - expect(order?.lines[1].productVariant.sku).toBe('L2201508'); - expect(order?.lines[1].quantity).toBe(1); - }); -}); - describe('Periodical stock updates', function () { it('Throws forbidden for invalid api key', async () => { const res = await adminClient.fetch( From e222785017743d6d4b44b3aef0c38d80433f4aa2 Mon Sep 17 00:00:00 2001 From: Martijn Date: Wed, 29 Nov 2023 08:46:59 +0100 Subject: [PATCH 09/12] feat(picqer): changelog --- packages/vendure-plugin-picqer/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vendure-plugin-picqer/CHANGELOG.md b/packages/vendure-plugin-picqer/CHANGELOG.md index 418e3f71..201d05d4 100644 --- a/packages/vendure-plugin-picqer/CHANGELOG.md +++ b/packages/vendure-plugin-picqer/CHANGELOG.md @@ -1,7 +1,7 @@ # 2.3.0 (2023-11-28) - Expose endpoint to periodically pull stock levels -- Load +- Install order process that allows skipping fulfillments when transitioning to Shipped or Delivered - # 2.2.4 (2023-11-21) From 6c8973b2d779a9852cc3bfe07b858506cf26cb66 Mon Sep 17 00:00:00 2001 From: Martijn Date: Wed, 29 Nov 2023 09:11:37 +0100 Subject: [PATCH 10/12] feat(picqer): typo --- packages/vendure-plugin-picqer/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vendure-plugin-picqer/README.md b/packages/vendure-plugin-picqer/README.md index 676953bb..36741daf 100644 --- a/packages/vendure-plugin-picqer/README.md +++ b/packages/vendure-plugin-picqer/README.md @@ -69,7 +69,7 @@ You can use a custom [StockLocationStrategy](https://github.com/vendure-ecommerc ### Periodical stock level sync -You can call the endpoint `/picqer/pull-stock-levels/`, with your Picqer API key as bearer token, to trigger a full stock level sync. This will pull stock levels from Picqer, and update them in Picqer. +You can call the endpoint `/picqer/pull-stock-levels/`, with your Picqer API key as bearer token, to trigger a full stock level sync. This will pull stock levels from Picqer, and update them in Vendure. ``` curl -H "Authorization: Bearer abcde-your-apikey" `http://localhost:3000/picqer/pull-stock-levels/your-channel-token` From 10572a45ff3b5ccf9a96b1da4b717e94939b5e6a Mon Sep 17 00:00:00 2001 From: Martijn Date: Wed, 29 Nov 2023 09:14:12 +0100 Subject: [PATCH 11/12] feat(picqer): typos --- packages/vendure-plugin-picqer/README.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/vendure-plugin-picqer/README.md b/packages/vendure-plugin-picqer/README.md index 36741daf..fe6eff50 100644 --- a/packages/vendure-plugin-picqer/README.md +++ b/packages/vendure-plugin-picqer/README.md @@ -77,21 +77,19 @@ curl -H "Authorization: Bearer abcde-your-apikey" `http://localhost:3000/picqer/ ### Order process override -This plugin installs the default order process with `checkFulfillmentStates: false` configured, so that orders can be transitioned to Shipped and Delivered without the need of fulfilment. Fulfilment is the responsibility of Picqer, so we wont handle that in Vendure when using this plugin. +This plugin installs the default order process with `checkFulfillmentStates: false` configured, so that orders can be transitioned to Shipped and Delivered without the need of fulfillment. Fulfillment is the responsibility of Picqer, so we won't handle that in Vendure when using this plugin. ![!image](https://www.plantuml.com/plantuml/png/VOv1IyD048Nl-HNl1rH9Uog1I8iNRnQYtfVCn0nkPkFk1F7VIvgjfb2yBM_VVEyx97FHfi4NZrvO3NSFU6EbANA58n4iO0Sn7jBy394u5hbmrUrTmhP4ij1-87JBoIteoNt3AI6ncUT_Y4VlG-kCB_lL0d_M9wTKRyiDN6vGlLiJJj9-SgpGiDB2XuMSuaki3vEXctmdVc2r8l-ijvjv2TD8ytuNcSz1lR_7wvA9NifmwKfil_OgRy5VejCa9a7_x9fUnf5fy-lNHdOc-fv5pwQfECoCmVy0) -- Without incoming stock from Picqer, items would be allocated indefinitely. Picqer has to tell Vendure what the stock levels of items are. +- Without incoming stock from Picqer, either via webhook or pulled from the Picqer API, items would be allocated indefinitely. Picqer has to tell Vendure what the stock level of products are. ## Orders 1. Orders are pushed to Picqer with status `processing` when an order is placed in Vendure. -2. The order is immediately fulfilled on order placement. -3. On incoming `order.completed` event from Picqer, the order is transitioned to `Shipped`. -4. There currently is no way of telling when an order is `Deliverd` based on Picqer events, so we automatically transition to `Delivered`. +2. On incoming `order.completed` event from Picqer, the order is transitioned to `Shipped`. +3. There currently is no way of telling when an order is `Delivered` based on Picqer events, so we automatically transition to `Delivered`. ## Caveats - Due to limitation of the Picqer API, the plugin only uploads images if no images exist for the product in Picqer. -- Stock is updated directly on a variant, so no `StockMovementEvents` are emitted by Vendure when variants are updated in Vendure by the full sync. - This plugin automatically creates webhooks and deactivates old ones. Webhooks are created when you save your config. From 6bc1f9908360da3637f694abbd047cfec40322a7 Mon Sep 17 00:00:00 2001 From: Martijn Date: Wed, 29 Nov 2023 09:21:48 +0100 Subject: [PATCH 12/12] feat(picqer): typos --- packages/vendure-plugin-picqer/src/api/picqer.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vendure-plugin-picqer/src/api/picqer.service.ts b/packages/vendure-plugin-picqer/src/api/picqer.service.ts index 2568eb2f..f2010c59 100644 --- a/packages/vendure-plugin-picqer/src/api/picqer.service.ts +++ b/packages/vendure-plugin-picqer/src/api/picqer.service.ts @@ -544,7 +544,7 @@ export class PicqerService implements OnApplicationBootstrap { await this.connection.getRepository(ctx, StockLevel).save({ id: stockLevelId, stockOnHand: picqerStock.freestock, - stockAllocated: 0, // Reset allocations based on Picqer events, because of our custom fulfilmentproces + stockAllocated: 0, // Reset allocations, because we skip fulfillment with this plugin }); // Add stock adjustment stockAdjustments.push(