Skip to content

Commit

Permalink
Merge pull request #301 from Pinelab-studio/fix/picqer-expose-stock
Browse files Browse the repository at this point in the history
Picqer: Expose stock level sync function
  • Loading branch information
martijnvdbrug authored Nov 29, 2023
2 parents 1d9ce2e + 6bc1f99 commit ab8823b
Show file tree
Hide file tree
Showing 11 changed files with 242 additions and 317 deletions.
1 change: 1 addition & 0 deletions packages/test/src/admin.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ query GetVariants {
outOfStockThreshold
trackInventory
stockAllocated
stockLevel
}
totalItems
}
Expand Down
83 changes: 82 additions & 1 deletion packages/test/src/generated/admin-graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2477,6 +2477,36 @@ export type ManualPaymentStateError = ErrorResult & {
message: Scalars['String'];
};

export enum MetricInterval {
Daily = 'Daily'
}

export type MetricSummary = {
__typename?: 'MetricSummary';
entries: Array<MetricSummaryEntry>;
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<Scalars['Boolean']>;
types: Array<MetricType>;
};

export enum MetricType {
AverageOrderValue = 'AverageOrderValue',
OrderCount = 'OrderCount',
OrderTotal = 'OrderTotal'
}

export type MimeTypeError = ErrorResult & {
__typename?: 'MimeTypeError';
errorCode: ErrorCode;
Expand Down Expand Up @@ -2777,6 +2807,8 @@ export type Mutation = {
transitionFulfillmentToState: TransitionFulfillmentToStateResult;
transitionOrderToState?: Maybe<TransitionOrderToStateResult>;
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 */
Expand Down Expand Up @@ -2832,6 +2864,8 @@ export type Mutation = {
updateTaxRate: TaxRate;
/** Update an existing Zone */
updateZone: Zone;
/** Upsert Picqer config for the current channel */
upsertPicqerConfig: PicqerConfig;
};


Expand Down Expand Up @@ -3647,6 +3681,11 @@ export type MutationUpdateZoneArgs = {
input: UpdateZoneInput;
};


export type MutationUpsertPicqerConfigArgs = {
input: PicqerConfigInput;
};

export type NativeAuthInput = {
password: Scalars['String'];
username: Scalars['String'];
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -4370,6 +4411,23 @@ export type PermissionDefinition = {
name: Scalars['String'];
};

export type PicqerConfig = {
__typename?: 'PicqerConfig';
apiEndpoint?: Maybe<Scalars['String']>;
apiKey?: Maybe<Scalars['String']>;
enabled?: Maybe<Scalars['Boolean']>;
storefrontUrl?: Maybe<Scalars['String']>;
supportEmail?: Maybe<Scalars['String']>;
};

export type PicqerConfigInput = {
apiEndpoint?: InputMaybe<Scalars['String']>;
apiKey?: InputMaybe<Scalars['String']>;
enabled?: InputMaybe<Scalars['Boolean']>;
storefrontUrl?: InputMaybe<Scalars['String']>;
supportEmail?: InputMaybe<Scalars['String']>;
};

export type PreviewCollectionVariantsInput = {
filters: Array<ConfigurableOperationInput>;
inheritFilters: Scalars['Boolean'];
Expand Down Expand Up @@ -4850,19 +4908,24 @@ export type Query = {
facets: FacetList;
fulfillmentHandlers: Array<ConfigurableOperationDefinition>;
globalSettings: GlobalSettings;
/** Test Picqer config against the Picqer API */
isPicqerConfigValid: Scalars['Boolean'];
job?: Maybe<Job>;
jobBufferSize: Array<JobBufferSize>;
jobQueues: Array<JobQueue>;
jobs: JobList;
jobsById: Array<Job>;
me?: Maybe<CurrentUser>;
/** Get metrics for the given interval and metric types. */
metricSummary: Array<MetricSummary>;
order?: Maybe<Order>;
orders: OrderList;
paymentMethod?: Maybe<PaymentMethod>;
paymentMethodEligibilityCheckers: Array<ConfigurableOperationDefinition>;
paymentMethodHandlers: Array<ConfigurableOperationDefinition>;
paymentMethods: PaymentMethodList;
pendingSearchIndexUpdates: Scalars['Int'];
picqerConfig?: Maybe<PicqerConfig>;
/** 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. */
Expand Down Expand Up @@ -4996,6 +5059,11 @@ export type QueryFacetsArgs = {
};


export type QueryIsPicqerConfigValidArgs = {
input: TestPicqerInput;
};


export type QueryJobArgs = {
jobId: Scalars['ID'];
};
Expand All @@ -5016,6 +5084,11 @@ export type QueryJobsByIdArgs = {
};


export type QueryMetricSummaryArgs = {
input?: InputMaybe<MetricSummaryInput>;
};


export type QueryOrderArgs = {
id: Scalars['ID'];
};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -6592,6 +6672,7 @@ export const GetVariants = gql`
outOfStockThreshold
trackInventory
stockAllocated
stockLevel
}
totalItems
}
Expand Down
2 changes: 2 additions & 0 deletions packages/test/src/generated/shop-graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
7 changes: 6 additions & 1 deletion packages/vendure-plugin-picqer/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
# 2.2.4 (2023-11-21)
# 2.3.0 (2023-11-28)

- Expose endpoint to periodically pull stock levels
- Install order process that allows skipping fulfillments when transitioning to Shipped or Delivered

- # 2.2.4 (2023-11-21)

- Take Picqer allocated stock into account when setting stock on hand

Expand Down
28 changes: 19 additions & 9 deletions packages/vendure-plugin-picqer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,26 +60,36 @@ 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-levels/<channeltoken>`.

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/<channeltoken>`, 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`
```

### Order process override

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 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.

### Order flow:
![!image](https://www.plantuml.com/plantuml/png/VOv1IyD048Nl-HNl1rH9Uog1I8iNRnQYtfVCn0nkPkFk1F7VIvgjfb2yBM_VVEyx97FHfi4NZrvO3NSFU6EbANA58n4iO0Sn7jBy394u5hbmrUrTmhP4ij1-87JBoIteoNt3AI6ncUT_Y4VlG-kCB_lL0d_M9wTKRyiDN6vGlLiJJj9-SgpGiDB2XuMSuaki3vEXctmdVc2r8l-ijvjv2TD8ytuNcSz1lR_7wvA9NifmwKfil_OgRy5VejCa9a7_x9fUnf5fy-lNHdOc-fv5pwQfECoCmVy0)

![Order flow](https://www.plantuml.com/plantuml/png/RSvD2W8n30NWVKyHkjS3p49c8MuT4DmFxLCBwOzfwlcbM45XTW_oyYLprLMqHJPN9Dy4j3lG4jmJGXCjhJueYuTGJYCKNXqYalvkVED4fyQtmFmfRw8NA6acMoGxr1hItPen_9FENQXxbsDXAFpclQwDnxfv18SN1DwQkLSYlm40)
- 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

[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. 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.
2 changes: 1 addition & 1 deletion packages/vendure-plugin-picqer/package.json
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>",
Expand Down
48 changes: 44 additions & 4 deletions packages/vendure-plugin-picqer/src/api/picqer.controller.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
import { Body, Controller, Headers, Param, Post, Req } from '@nestjs/common';
import { Logger } from '@vendure/core';
import {
Controller,
ForbiddenException,
Headers,
Param,
Post,
Req,
BadRequestException,
Get,
} 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(
Expand Down Expand Up @@ -39,4 +51,32 @@ export class PicqerController {
throw e;
}
}

@Get('pull-stock-levels/:channelToken')
async pullStockLevels(
@Headers('Authorization') authHeader: string,
@Param('channelToken') channelToken: string
): Promise<void> {
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);
}
}
27 changes: 25 additions & 2 deletions packages/vendure-plugin-picqer/src/api/picqer.resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -30,7 +32,28 @@ export class PicqerResolver {
@Mutation()
@Allow(picqerPermission.Permission)
async triggerPicqerFullSync(@Ctx() ctx: RequestContext): Promise<boolean> {
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()
Expand Down
Loading

0 comments on commit ab8823b

Please sign in to comment.