Skip to content

Commit

Permalink
Merge pull request #305 from Pinelab-studio/feat/picqer-inactive-ware…
Browse files Browse the repository at this point in the history
…houses

Feat/picqer inactive warehouses
  • Loading branch information
martijnvdbrug authored Dec 12, 2023
2 parents f3a756f + ccc83ba commit dd2039e
Show file tree
Hide file tree
Showing 10 changed files with 163 additions and 90 deletions.
6 changes: 5 additions & 1 deletion packages/vendure-plugin-picqer/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# 2.4.0 (2023-12-12)

- Sync warehouses including names on full sync and stock level sync

# 2.3.1 (2023-12-05)

- Throw error when trying to fulfill, because fulfilment shouldn't be used together with Picqer.
Expand All @@ -7,7 +11,7 @@
- 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)
# 2.2.4 (2023-11-21)

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

Expand Down
4 changes: 2 additions & 2 deletions packages/vendure-plugin-picqer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +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. On incoming webhook from Picqer
3. Or, on trigger of the GET endpoint `/picqer/pull-stock-levels/<channeltoken>`.
2. Or, on trigger of the GET endpoint `/picqer/pull-stock-levels/<channeltoken>`.
3. On incoming webhook from Picqer. Before incoming webhooks work, you need a full sync or pull-stock-levels sync, so that stock locations are created in Vendure based on the Picqer Warehouses

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.

Expand Down
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.3.1",
"version": "2.4.0",
"description": "Vendure plugin syncing to orders and stock with Picqer",
"icon": "truck",
"author": "Martijn van de Brug <[email protected]>",
Expand Down
29 changes: 27 additions & 2 deletions packages/vendure-plugin-picqer/src/api/picqer.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
ProductData,
ProductInput,
VatGroup,
Warehouse,
WebhookData,
WebhookInput,
} from './types';
Expand All @@ -24,8 +25,7 @@ export interface PicqerClientInput {
export class PicqerClient {
readonly instance: AxiosInstance;
/**
* This is the default limit for lists in the Picqer API.
* Resultsets greater than this will require pagination.
* This is the default limit for everything that uses pagination in the Picqer API.
*/
readonly responseLimit = 100;

Expand Down Expand Up @@ -180,6 +180,31 @@ export class PicqerClient {
return customer;
}

async getAllWarehouses(): Promise<Warehouse[]> {
const allWarehouses: Warehouse[] = [];
let hasMore = true;
let offset = 0;
while (hasMore) {
const warehouses: Warehouse[] = await this.rawRequest(
'get',
`/warehouses?offset=${offset}`
);
Logger.info(`Fetched ${warehouses.length} warehouses`, loggerCtx);
allWarehouses.push(...warehouses);
if (warehouses.length < this.responseLimit) {
hasMore = false;
} else {
Logger.info(`Fetching more...`, loggerCtx);
}
offset += this.responseLimit;
}
Logger.info(
`Fetched a total of ${allWarehouses.length} warehouses`,
loggerCtx
);
return allWarehouses;
}

async createCustomer(input: CustomerInput): Promise<CustomerData> {
return this.rawRequest('post', `/customers/`, input);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,6 @@ export class PicqerController {
if (picqerConfig?.apiKey !== apiKey) {
throw new ForbiddenException('Invalid bearer token');
}
await this.picqerService.createStockLevelJobs(ctx);
await this.picqerService.createStockLevelJob(ctx);
}
}
2 changes: 1 addition & 1 deletion packages/vendure-plugin-picqer/src/api/picqer.resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export class PicqerResolver {
async triggerPicqerFullSync(@Ctx() ctx: RequestContext): Promise<boolean> {
let allSucceeded = true;
await this.service
.createStockLevelJobs(ctx)
.createStockLevelJob(ctx)
.catch((e: Error | undefined) => {
Logger.error(
`Failed to create jobs to pull stock levels from Picqer: ${e?.message}`,
Expand Down
172 changes: 102 additions & 70 deletions packages/vendure-plugin-picqer/src/api/picqer.service.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import { Inject, Injectable, OnApplicationBootstrap } from '@nestjs/common';
import {
OrderAddress,
OrderLineInput,
UpdateProductVariantInput,
} from '@vendure/common/lib/generated-types';
import {
Address,
assertFound,
AssetService,
ChannelService,
ConfigService,
Expand All @@ -15,7 +13,6 @@ import {
ErrorResult,
EventBus,
ForbiddenError,
FulfillmentStateTransitionError,
ID,
JobQueue,
JobQueueService,
Expand All @@ -38,7 +35,6 @@ import {
TransactionalConnection,
} from '@vendure/core';
import { StockAdjustment } from '@vendure/core/dist/entity/stock-movement/stock-adjustment.entity';
import { StockMovement } from '@vendure/core/dist/entity/stock-movement/stock-movement.entity';
import currency from 'currency.js';
import util from 'util';
import { loggerCtx, PLUGIN_INIT_OPTIONS } from '../constants';
Expand Down Expand Up @@ -93,8 +89,6 @@ interface PushOrderJob {

type JobData = PushVariantsJob | PullStockLevelsJob | PushOrderJob;

const STOCK_LOCATION_PREFIX = 'Picqer Warehouse';

@Injectable()
export class PicqerService implements OnApplicationBootstrap {
private jobQueue!: JobQueue<JobData>;
Expand Down Expand Up @@ -342,7 +336,7 @@ export class PicqerService implements OnApplicationBootstrap {
/**
* Create job to pull stock levels of all products from Picqer
*/
async createStockLevelJobs(ctx: RequestContext): Promise<void> {
async createStockLevelJob(ctx: RequestContext): Promise<void> {
await this.jobQueue.add(
{
action: 'pull-stock-levels',
Expand Down Expand Up @@ -472,7 +466,7 @@ export class PicqerService implements OnApplicationBootstrap {
}

/**
* Pulls all products from Picqer and updates the stock levels in Vendure
* Sync warehouses, pull all products from Picqer and updates the stock levels in Vendure
* based on the stock levels from Picqer products
*/
async handlePullStockLevelsJob(userCtx: RequestContext): Promise<void> {
Expand All @@ -481,11 +475,104 @@ export class PicqerService implements OnApplicationBootstrap {
if (!client) {
return;
}
await this.syncWarehouses(ctx).catch((e: any) => {
Logger.error(
`Failed to sync warehouses with Picqer: ${e?.message}`,
loggerCtx,
util.inspect(e)
);
});
const picqerProducts = await client.getAllActiveProducts();
await this.updateStockBySkus(ctx, picqerProducts);
Logger.info(`Successfully pulled stock levels from Picqer`, loggerCtx);
}

/**
* Fetch warehouses from Picqer and save as stock location in Vendure
* Deletes any warehouses from Vendure that are not active in Picqer
*/
async syncWarehouses(ctx: RequestContext): Promise<void> {
const client = await this.getClient(ctx);
if (!client) {
return;
}
// List of Vendure location ID's that are created/updated based on Picqer warehouses
const syncedFromPicqer: ID[] = [];
const warehouses = await client.getAllWarehouses();
for (const warehouse of warehouses) {
const existing = await this.getStockLocation(ctx, warehouse.idwarehouse);
if (!warehouse.active) {
continue;
}
const stockLocationName = `Picqer ${warehouse.idwarehouse}: ${warehouse.name}`;
const stocklocationDescription = `Mirrored warehouse from Picqer '${warehouse.name}' (${warehouse.idwarehouse})`;
if (existing) {
await this.stockLocationService.update(ctx, {
id: existing.id,
name: stockLocationName,
description: stocklocationDescription,
});
syncedFromPicqer.push(existing.id);
Logger.info(`Updated stock location '${stockLocationName}'`, loggerCtx);
} else {
const created = await this.stockLocationService.create(ctx, {
name: stockLocationName,
description: stocklocationDescription,
});
syncedFromPicqer.push(created.id);
Logger.info(
`Created new stock location '${stockLocationName}'`,
loggerCtx
);
}
}
// Delete non-picqer warehouses
const locations = await this.stockLocationService.findAll(ctx);
// Delete locations that are not Picqer based
const locationsToDelete = locations.items.filter(
(l) => !syncedFromPicqer.includes(l.id)
);
for (const location of locationsToDelete) {
const res = await this.stockLocationService.delete(ctx, {
id: location.id,
});
if (res.result === 'DELETED') {
Logger.info(`Deleted stock location ${location.name}`, loggerCtx);
} else {
Logger.error(
`Failed to delete stock location ${location.name}: ${res.message}`,
loggerCtx
);
}
}
Logger.info(`Successfully synced warehouses from Picqer`, loggerCtx);
}

/**
* Get stock locations based on the stock locations we receive from Picqer
* @returns The Vendure stock location that mirrors the Picqer warehouse
*/
async getStockLocation(
ctx: RequestContext,
picqerLocationId: number
): Promise<StockLocation | undefined> {
// Picqer location ID's are also used as the ID of the Vendure stock location
// return await this.stockLocationService.findOne(ctx, picqerLocationId);
const { items } = await this.stockLocationService.findAll(ctx, {
filter: {
name: { contains: `Picqer ${picqerLocationId}` },
},
});
const location = items[0];
if (items.length > 1) {
Logger.error(
`Found multiple locations with name "Picqer ${picqerLocationId}", there should be only one! Using location with ID ${location.id}`,
loggerCtx
);
}
return location;
}

/**
* Update variant stock in Vendure based on given Picqer products
*/
Expand Down Expand Up @@ -528,10 +615,16 @@ export class PicqerService implements OnApplicationBootstrap {
);
continue;
}
const location = await this.getOrCreateStockLocation(
const location = await this.getStockLocation(
ctx,
picqerStock.idwarehouse
);
if (!location) {
Logger.info(
`Not updating stock of warehouse ${picqerStock.idwarehouse}, because it doesn't exist in Vendure. You might need to re-sync stock levels and locations if this is an active warehouse.`
);
continue;
}
const { id: stockLevelId, stockOnHand } =
await this.stockLevelService.getStockLevel(
ctx,
Expand Down Expand Up @@ -563,74 +656,13 @@ export class PicqerService implements OnApplicationBootstrap {
);
return;
}
await this.removeNonPicqerStockLocations(ctx);
await this.eventBus.publish(new StockMovementEvent(ctx, stockAdjustments));
Logger.info(
`Updated stock levels of ${stockAdjustments.length} variants`,
loggerCtx
);
}

/**
* Get or create stock locations based on the stock locations we receive from Picqer
* @returns The Vendure stock locations that mirror Picqers stock locations
*/
async getOrCreateStockLocation(
ctx: RequestContext,
picqerLocationId: number
): Promise<StockLocation> {
const name = `${STOCK_LOCATION_PREFIX} ${picqerLocationId}`;
const existingLocations = await this.stockLocationService.findAll(ctx, {
filter: { name: { eq: name } },
take: 1,
});
if (existingLocations.totalItems > 1) {
Logger.error(
`Found multiple stock locations with name "${name}", only 1 mirrored location should exist!`,
loggerCtx
);
}
if (existingLocations.items[0]) {
return existingLocations.items[0];
}
return this.stockLocationService.create(ctx, {
name,
description: `Mirrored location from Picqer warehouse with id ${picqerLocationId}`,
});
}

/**
* Removes any stock locations that are not Picqer warehouses for given variant
* This is determined by the "Picqer" preview in the name
*/
async removeNonPicqerStockLocations(ctx: RequestContext): Promise<void> {
const locations = await this.stockLocationService.findAll(ctx);
await Promise.all(
locations.items.map(async (location) => {
if (!location.name.startsWith(STOCK_LOCATION_PREFIX)) {
// Delete stock movements first, because of foreign key constraint
await this.connection
.getRepository(ctx, StockMovement)
.delete({ stockLocationId: location.id });
// Delete stock location
const { result, message } = await this.stockLocationService.delete(
ctx,
{ id: location.id }
);
if (result === 'NOT_DELETED') {
throw Error(
`Failed to delete stock location ${location.name}: ${result}: ${message}`
);
}
Logger.warn(
`Removed stock location ${location.name}, because it's not a Picqer managed location`,
loggerCtx
);
}
})
);
}

/**
* Fulfil the order first, then pushes the order to Picqer
*/
Expand Down
9 changes: 9 additions & 0 deletions packages/vendure-plugin-picqer/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ export interface Stock {
freepickablestock: number;
}

export interface Warehouse {
idwarehouse: number;
name: string;
accept_orders: boolean;
counts_for_general_stock: boolean;
priority: number;
active: boolean;
}

export interface CustomerData {
idcustomer: number;
idtemplate: any;
Expand Down
Loading

0 comments on commit dd2039e

Please sign in to comment.