@pinelab/vendure-plugin-qls-fulfillment 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,309 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var __metadata = (this && this.__metadata) || function (k, v) {
9
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
+ };
11
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
12
+ return function (target, key) { decorator(target, key, paramIndex); }
13
+ };
14
+ var __importDefault = (this && this.__importDefault) || function (mod) {
15
+ return (mod && mod.__esModule) ? mod : { "default": mod };
16
+ };
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.QlsProductService = void 0;
19
+ const common_1 = require("@nestjs/common");
20
+ const core_1 = require("@vendure/core");
21
+ const catch_unknown_1 = require("catch-unknown");
22
+ const util_1 = __importDefault(require("util"));
23
+ const constants_1 = require("../constants");
24
+ const qls_client_1 = require("../lib/qls-client");
25
+ let QlsProductService = class QlsProductService {
26
+ constructor(connection, options, jobQueueService, stockLevelService, variantService, stockLocationService, eventBus) {
27
+ this.connection = connection;
28
+ this.options = options;
29
+ this.jobQueueService = jobQueueService;
30
+ this.stockLevelService = stockLevelService;
31
+ this.variantService = variantService;
32
+ this.stockLocationService = stockLocationService;
33
+ this.eventBus = eventBus;
34
+ }
35
+ onApplicationBootstrap() {
36
+ // Listen for ProductVariantEvent and add a job to the queue
37
+ this.eventBus.ofType(core_1.ProductVariantEvent).subscribe((event) => {
38
+ if (event.type !== 'created' && event.type !== 'updated') {
39
+ return;
40
+ }
41
+ this.triggerSyncVariants(event.ctx, event.entity.map((v) => v.id)).catch((e) => {
42
+ const error = (0, catch_unknown_1.asError)(e);
43
+ core_1.Logger.error(`Error adding job to queue: ${error.message}`, constants_1.loggerCtx, error.stack);
44
+ });
45
+ });
46
+ }
47
+ async onModuleInit() {
48
+ this.productJobQueue = await this.jobQueueService.createQueue({
49
+ name: 'qls-product-jobs',
50
+ process: (job) => {
51
+ return this.handleProductJob(job);
52
+ },
53
+ });
54
+ }
55
+ /**
56
+ * Decide what kind of job it is and handle accordingly.
57
+ * Returns the result of the job, which will be stored in the job record.
58
+ */
59
+ async handleProductJob(job) {
60
+ try {
61
+ const ctx = core_1.RequestContext.deserialize(job.data.ctx);
62
+ if (job.data.action === 'full-sync-products') {
63
+ return await this.runFullSync(ctx);
64
+ }
65
+ else if (job.data.action === 'sync-products') {
66
+ return await this.syncVariants(ctx, job.data.productVariantIds);
67
+ }
68
+ throw new Error(`Unknown job action: ${job.data.action}`);
69
+ }
70
+ catch (e) {
71
+ const error = (0, catch_unknown_1.asError)(e);
72
+ const dataWithoutCtx = {
73
+ ...job.data,
74
+ ctx: undefined,
75
+ };
76
+ core_1.Logger.error(`Error handling job ${job.data.action}: ${error}`, constants_1.loggerCtx, util_1.default.inspect(dataWithoutCtx, false, 5));
77
+ throw error;
78
+ }
79
+ }
80
+ /**
81
+ * Create fulfillment products in QLS for all product variants (full push)
82
+ * 1. Fetches all products from QLS
83
+ * 2. Updates stock levels in Vendure based on the QLS products
84
+ * 3. Creates products in QLS if needed
85
+ * 4. Updates products in QLS if needed
86
+ */
87
+ async runFullSync(ctx) {
88
+ try {
89
+ const client = await (0, qls_client_1.getQlsClient)(ctx, this.options);
90
+ if (!client) {
91
+ throw new Error(`QLS not enabled for channel ${ctx.channel.token}`);
92
+ }
93
+ core_1.Logger.info(`Running full sync for channel ${ctx.channel.token}`, constants_1.loggerCtx);
94
+ const allQlsProducts = await client.getAllFulfillmentProducts();
95
+ const allVariants = await this.getAllVariants(ctx);
96
+ core_1.Logger.info(`Running full sync for ${allQlsProducts.length} QLS products and ${allVariants.length} Vendure variants`, constants_1.loggerCtx);
97
+ // Update stock in Vendure based on QLS products
98
+ let updateStockCount = 0;
99
+ for (const variant of allVariants) {
100
+ const qlsProduct = allQlsProducts.find((p) => p.sku == variant.sku);
101
+ if (qlsProduct) {
102
+ await this.updateStock(ctx, variant.id, qlsProduct.amount_available);
103
+ updateStockCount += 1;
104
+ }
105
+ }
106
+ core_1.Logger.info(`Updated stock for ${updateStockCount} variants based on QLS stock levels`, constants_1.loggerCtx);
107
+ // Create or update products in QLS
108
+ let createdQlsProductsCount = 0;
109
+ let updatedQlsProductsCount = 0;
110
+ for (const variant of allVariants) {
111
+ const existingQlsProduct = allQlsProducts.find((p) => p.sku == variant.sku);
112
+ const result = await this.createOrUpdateProductInQls(ctx, client, variant, existingQlsProduct ?? null);
113
+ if (result === 'created') {
114
+ createdQlsProductsCount += 1;
115
+ }
116
+ else if (result === 'updated') {
117
+ updatedQlsProductsCount += 1;
118
+ }
119
+ }
120
+ core_1.Logger.info(`Created ${createdQlsProductsCount} products in QLS`, constants_1.loggerCtx);
121
+ core_1.Logger.info(`Updated ${updatedQlsProductsCount} products in QLS`, constants_1.loggerCtx);
122
+ return {
123
+ updatedInQls: updatedQlsProductsCount,
124
+ createdInQls: createdQlsProductsCount,
125
+ updatedStock: updateStockCount,
126
+ };
127
+ }
128
+ catch (e) {
129
+ const error = (0, catch_unknown_1.asError)(e);
130
+ core_1.Logger.error(`Error running full sync: ${error.message}`, constants_1.loggerCtx, error.stack);
131
+ throw error;
132
+ }
133
+ }
134
+ /**
135
+ * Creates or updates the fulfillment products in QLS for the given product variants.
136
+ */
137
+ async syncVariants(ctx, productVariantIds) {
138
+ const client = await (0, qls_client_1.getQlsClient)(ctx, this.options);
139
+ if (!client) {
140
+ core_1.Logger.debug(`QLS not enabled for channel ${ctx.channel.token}. Not handling product update/create.`, constants_1.loggerCtx);
141
+ return {
142
+ updatedInQls: 0,
143
+ createdInQls: 0,
144
+ updatedStock: 0,
145
+ };
146
+ }
147
+ let updatedInQls = 0;
148
+ let createdInQls = 0;
149
+ for (const variantId of productVariantIds) {
150
+ const variant = await this.variantService.findOne(ctx, variantId);
151
+ if (!variant) {
152
+ core_1.Logger.error(`Variant with id ${variantId} not found. Not creating or updating product in QLS.`, constants_1.loggerCtx);
153
+ continue;
154
+ }
155
+ const existingQlsProduct = await client.getFulfillmentProductBySku(variant.sku);
156
+ const result = await this.createOrUpdateProductInQls(ctx, client, variant, existingQlsProduct ?? null);
157
+ if (result === 'created') {
158
+ createdInQls += 1;
159
+ }
160
+ else if (result === 'updated') {
161
+ updatedInQls += 1;
162
+ }
163
+ }
164
+ return {
165
+ updatedInQls,
166
+ createdInQls,
167
+ updatedStock: 0,
168
+ };
169
+ }
170
+ /**
171
+ * Trigger a full product sync job
172
+ */
173
+ async triggerFullSync(ctx) {
174
+ return this.productJobQueue.add({
175
+ action: 'full-sync-products',
176
+ ctx: ctx.serialize(),
177
+ }, { retries: 5 });
178
+ }
179
+ /**
180
+ * Trigger a product sync job for particular product variants
181
+ */
182
+ async triggerSyncVariants(ctx, productVariantIds) {
183
+ const client = await (0, qls_client_1.getQlsClient)(ctx, this.options);
184
+ if (!client) {
185
+ core_1.Logger.debug(`QLS not enabled for channel ${ctx.channel.token}. Not triggering product sync.`, constants_1.loggerCtx);
186
+ return;
187
+ }
188
+ return this.productJobQueue.add({
189
+ action: 'sync-products',
190
+ ctx: ctx.serialize(),
191
+ productVariantIds,
192
+ }, { retries: 5 });
193
+ }
194
+ /**
195
+ * Update the stock level for a variant based on the given available stock
196
+ */
197
+ async updateStockBySku(ctx, sku, availableStock) {
198
+ const result = await this.variantService.findAll(ctx, {
199
+ filter: { sku: { eq: sku } },
200
+ });
201
+ if (!result.items.length) {
202
+ throw new Error(`Variant with sku '${sku}' not found`);
203
+ }
204
+ const variant = result.items[0];
205
+ if (result.items.length > 1) {
206
+ core_1.Logger.error(`Multiple variants found for sku '${sku}', using '${variant.id}'`, constants_1.loggerCtx);
207
+ }
208
+ return this.updateStock(ctx, variant.id, availableStock);
209
+ }
210
+ /**
211
+ * Determines if a product needs to be created or updated in QLS based on the given variant and existing QLS product.
212
+ */
213
+ async createOrUpdateProductInQls(ctx, client, variant, existingProduct) {
214
+ let qlsProductId = existingProduct?.id;
215
+ let createdOrUpdated = 'not-changed';
216
+ if (!existingProduct) {
217
+ const result = await client.createFulfillmentProduct({
218
+ name: variant.name,
219
+ sku: variant.sku,
220
+ ...this.options.getAdditionalVariantFields?.(ctx, variant),
221
+ });
222
+ qlsProductId = result.id;
223
+ core_1.Logger.info(`Created product '${variant.sku}' in QLS`, constants_1.loggerCtx);
224
+ createdOrUpdated = 'created';
225
+ }
226
+ else if (this.shouldUpdateProductInQls(ctx, variant, existingProduct)) {
227
+ await client.updateFulfillmentProduct(existingProduct.id, {
228
+ sku: variant.sku,
229
+ name: variant.name,
230
+ ...this.options.getAdditionalVariantFields?.(ctx, variant),
231
+ });
232
+ core_1.Logger.info(`Updated product '${variant.sku}' in QLS`, constants_1.loggerCtx);
233
+ createdOrUpdated = 'updated';
234
+ }
235
+ if (qlsProductId !== variant.customFields.qlsProductId) {
236
+ // Update variant with QLS product ID if it changed
237
+ // Do not use variantService.update because it will trigger a change event and cause an infinite loop
238
+ await this.connection
239
+ .getRepository(ctx, core_1.ProductVariant)
240
+ .update({ id: variant.id }, { customFields: { qlsProductId } });
241
+ core_1.Logger.info(`Set QLS product ID for variant '${variant.sku}' to ${qlsProductId}`, constants_1.loggerCtx);
242
+ }
243
+ return createdOrUpdated;
244
+ }
245
+ /**
246
+ * Determine if a product needs to be updated in QLS based on the given variant and QLS product.
247
+ */
248
+ shouldUpdateProductInQls(ctx, variant, qlsProduct) {
249
+ const additionalFields = this.options.getAdditionalVariantFields?.(ctx, variant);
250
+ if (qlsProduct.name !== variant.name ||
251
+ qlsProduct.ean !== additionalFields?.ean ||
252
+ qlsProduct.image_url !== additionalFields?.image_url) {
253
+ // If name or ean has changed, product should be updated in QLS
254
+ return true;
255
+ }
256
+ return false;
257
+ }
258
+ /**
259
+ * Get all variants for the current channel in batches
260
+ */
261
+ async getAllVariants(ctx) {
262
+ const allVariants = [];
263
+ let skip = 0;
264
+ const take = 100;
265
+ let hasMore = true;
266
+ while (hasMore) {
267
+ const result = await this.variantService.findAll(ctx, {
268
+ filter: { deletedAt: { isNull: true } },
269
+ skip,
270
+ take,
271
+ });
272
+ if (!result.items.length) {
273
+ break;
274
+ }
275
+ allVariants.push(...result.items);
276
+ if (allVariants.length >= result.totalItems) {
277
+ hasMore = false;
278
+ }
279
+ skip += take;
280
+ }
281
+ return allVariants;
282
+ }
283
+ /**
284
+ * Update stock level for a variant based on the given available stock
285
+ */
286
+ async updateStock(ctx, variantId, availableStock) {
287
+ // Find default Stock Location
288
+ const defaultStockLocation = await this.stockLocationService.defaultStockLocation(ctx);
289
+ // Get current stock level id
290
+ const { id: stockLevelId } = await this.stockLevelService.getStockLevel(ctx, variantId, defaultStockLocation.id);
291
+ // Update stock level
292
+ await this.connection.getRepository(ctx, core_1.StockLevel).save({
293
+ id: stockLevelId,
294
+ stockOnHand: availableStock,
295
+ stockAllocated: 0, // Reset allocations, because allocation is handled by QLS
296
+ });
297
+ core_1.Logger.info(`Updated stock for variant ${variantId} to ${availableStock}`, constants_1.loggerCtx);
298
+ }
299
+ };
300
+ exports.QlsProductService = QlsProductService;
301
+ exports.QlsProductService = QlsProductService = __decorate([
302
+ (0, common_1.Injectable)(),
303
+ __param(1, (0, common_1.Inject)(constants_1.PLUGIN_INIT_OPTIONS)),
304
+ __metadata("design:paramtypes", [core_1.TransactionalConnection, Object, core_1.JobQueueService,
305
+ core_1.StockLevelService,
306
+ core_1.ProductVariantService,
307
+ core_1.StockLocationService,
308
+ core_1.EventBus])
309
+ ], QlsProductService);
@@ -0,0 +1,62 @@
1
+ import { ID, Injector, Order, ProductVariant, RequestContext, SerializedRequestContext } from '@vendure/core';
2
+ import { CustomValue, FulfillmentProductInput } from './lib/client-types';
3
+ export interface QlsPluginOptions {
4
+ /**
5
+ * Get the QLS client config for the current channel based on given context
6
+ */
7
+ getConfig: (ctx: RequestContext) => QlsClientConfig | undefined | Promise<QlsClientConfig | undefined>;
8
+ /**
9
+ * Required to get the EAN and image URL for a product variant.
10
+ * Also allows you to override other product attributes like name, price, etc.
11
+ */
12
+ getAdditionalVariantFields?: (ctx: RequestContext, variant: ProductVariant) => Partial<FulfillmentProductInput>;
13
+ /**
14
+ * Function to get the set service point code for an order.
15
+ * Return undefined to not use a service point at all.
16
+ */
17
+ getAdditionalOrderFields?: (ctx: RequestContext, injector: Injector, order: Order) => Promise<AdditionalOrderFields | undefined> | AdditionalOrderFields | undefined;
18
+ /**
19
+ * A key used to verify if the caller is authorized to call the webhook.
20
+ * Set this to a random string
21
+ */
22
+ webhookSecret: string;
23
+ }
24
+ export interface AdditionalOrderFields {
25
+ servicepoint_code?: string;
26
+ delivery_options?: string[];
27
+ custom_values?: CustomValue[];
28
+ }
29
+ /**
30
+ * Job data required for pushing an order to QLS
31
+ */
32
+ export interface QlsOrderJobData {
33
+ action: 'push-order';
34
+ ctx: SerializedRequestContext;
35
+ orderId: ID;
36
+ }
37
+ export type QlsProductJobData = FullProductsSyncJobData | ProductsSyncJobData;
38
+ /**
39
+ * Job data required for pushing products to QLS (full sync)
40
+ */
41
+ export interface FullProductsSyncJobData {
42
+ action: 'full-sync-products';
43
+ ctx: SerializedRequestContext;
44
+ }
45
+ /**
46
+ * Job data required for creating/updating fulfillment products in QLS (no full sync)
47
+ */
48
+ export interface ProductsSyncJobData {
49
+ action: 'sync-products';
50
+ ctx: SerializedRequestContext;
51
+ productVariantIds: ID[];
52
+ }
53
+ export interface QlsClientConfig {
54
+ username: string;
55
+ password: string;
56
+ companyId: string;
57
+ brandId: string;
58
+ /**
59
+ * defaults to 'https://api.pakketdienstqls.nl'
60
+ */
61
+ url?: string;
62
+ }
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@pinelab/vendure-plugin-qls-fulfillment",
3
+ "version": "1.0.0-beta.1",
4
+ "description": "Vendure plugin to fulfill orders via QLS.",
5
+ "keywords": [
6
+ "fulfillment",
7
+ "fulfillment center",
8
+ "qls"
9
+ ],
10
+ "author": "Martijn van de Brug <martijn@pinelab.studio>",
11
+ "homepage": "https://plugins.pinelab.studio/",
12
+ "repository": "https://github.com/Pinelab-studio/pinelab-vendure-plugins",
13
+ "license": "MIT",
14
+ "private": false,
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "main": "dist/index.js",
19
+ "types": "dist/index.d.ts",
20
+ "files": [
21
+ "dist",
22
+ "README.md",
23
+ "CHANGELOG.md"
24
+ ],
25
+ "scripts": {
26
+ "build": "rimraf dist && yarn generate && tsc",
27
+ "start": "nodemon --watch src --exec ts-node test/dev-server.ts",
28
+ "generate": "graphql-codegen",
29
+ "test": "vitest run --bail 1",
30
+ "lint": "eslint ."
31
+ },
32
+ "dependencies": {
33
+ "catch-unknown": "^2.0.0"
34
+ }
35
+ }