@pinelab/vendure-plugin-qls-fulfillment 1.0.0-beta.1 → 1.0.0-beta.11
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.
- package/README.md +6 -1
- package/dist/lib/client-types.d.ts +14 -1
- package/dist/lib/qls-client.d.ts +8 -0
- package/dist/lib/qls-client.js +17 -2
- package/dist/services/qls-order.service.js +1 -0
- package/dist/services/qls-product.service.d.ts +10 -3
- package/dist/services/qls-product.service.js +135 -39
- package/dist/services/util.d.ts +15 -0
- package/dist/services/util.js +31 -0
- package/dist/services/util.spec.d.ts +1 -0
- package/dist/services/util.spec.js +60 -0
- package/dist/types.d.ts +13 -1
- package/dist/ui/providers.ts +74 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -58,7 +58,12 @@ Make sure to monitor failed jobs: A job that failed after its retries were exhau
|
|
|
58
58
|
1. An order was not pushed to QLS
|
|
59
59
|
2. A product was not synced to QLS
|
|
60
60
|
|
|
61
|
-
Monitor your logs for the
|
|
61
|
+
Monitor your logs for the following text:
|
|
62
|
+
|
|
63
|
+
- `QLS webhook error` - This means an incoming stock update webhook was not processed correctly.
|
|
64
|
+
- `Error creating or updating variant` - This means a product was not synced to QLS.
|
|
65
|
+
|
|
66
|
+
Make sure to filter by logger context `QlsPlugin`, to prevent false positive alerts.
|
|
62
67
|
|
|
63
68
|
## Cancelling orders and manually pushing orders to QLS
|
|
64
69
|
|
|
@@ -30,6 +30,9 @@ export type FulfillmentProduct = {
|
|
|
30
30
|
collection_id: string | null;
|
|
31
31
|
dimensions: unknown;
|
|
32
32
|
article_number: string | null;
|
|
33
|
+
/**
|
|
34
|
+
* Main EAN of the product
|
|
35
|
+
*/
|
|
33
36
|
ean: string;
|
|
34
37
|
name: string;
|
|
35
38
|
sku: string;
|
|
@@ -53,8 +56,18 @@ export type FulfillmentProduct = {
|
|
|
53
56
|
amount_blocked: number;
|
|
54
57
|
status: string | null;
|
|
55
58
|
suppliers: unknown[];
|
|
56
|
-
barcodes:
|
|
59
|
+
barcodes: Array<{
|
|
60
|
+
id: number;
|
|
61
|
+
fulfillment_product_id: string;
|
|
62
|
+
company_id: string;
|
|
63
|
+
barcode: string;
|
|
64
|
+
created: string;
|
|
65
|
+
modified: string;
|
|
66
|
+
}>;
|
|
57
67
|
image_url_handheld: string | null;
|
|
68
|
+
/**
|
|
69
|
+
* All EANs of the product, including the main EAN
|
|
70
|
+
*/
|
|
58
71
|
barcodes_and_ean: string[];
|
|
59
72
|
};
|
|
60
73
|
export interface FulfillmentOrderInput {
|
package/dist/lib/qls-client.d.ts
CHANGED
|
@@ -22,5 +22,13 @@ export declare class QlsClient {
|
|
|
22
22
|
createFulfillmentProduct(data: FulfillmentProductInput): Promise<FulfillmentProduct>;
|
|
23
23
|
updateFulfillmentProduct(fulfillmentProductId: string, data: FulfillmentProductInput): Promise<FulfillmentProduct>;
|
|
24
24
|
createFulfillmentOrder(data: Omit<FulfillmentOrderInput, 'brand_id'>): Promise<FulfillmentOrder>;
|
|
25
|
+
/**
|
|
26
|
+
* Add an extra barcode to a fulfillment product in QLS
|
|
27
|
+
*/
|
|
28
|
+
addBarcode(productId: string, barcode: string): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Add an extra barcode to a fulfillment product in QLS
|
|
31
|
+
*/
|
|
32
|
+
removeBarcode(productId: string, barcodeId: number): Promise<void>;
|
|
25
33
|
rawRequest<T>(method: 'POST' | 'GET' | 'PUT' | 'DELETE', action: string, data?: unknown): Promise<QlsApiResponse<T>>;
|
|
26
34
|
}
|
package/dist/lib/qls-client.js
CHANGED
|
@@ -50,7 +50,8 @@ class QlsClient {
|
|
|
50
50
|
allProducts.push(...result.data);
|
|
51
51
|
hasNextPage = result.pagination?.nextPage ?? false;
|
|
52
52
|
page++;
|
|
53
|
-
|
|
53
|
+
core_1.Logger.info(`Fetched products ${allProducts.length}/${result.pagination?.count}`, constants_1.loggerCtx);
|
|
54
|
+
await new Promise((resolve) => setTimeout(resolve, 700)); // Limit to ~86/minute, because the rate limit is 500/5 minutes
|
|
54
55
|
}
|
|
55
56
|
return allProducts;
|
|
56
57
|
}
|
|
@@ -69,6 +70,20 @@ class QlsClient {
|
|
|
69
70
|
});
|
|
70
71
|
return response.data;
|
|
71
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Add an extra barcode to a fulfillment product in QLS
|
|
75
|
+
*/
|
|
76
|
+
async addBarcode(productId, barcode) {
|
|
77
|
+
await this.rawRequest('POST', `fulfillment/products/${productId}/barcodes`, {
|
|
78
|
+
barcode,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Add an extra barcode to a fulfillment product in QLS
|
|
83
|
+
*/
|
|
84
|
+
async removeBarcode(productId, barcodeId) {
|
|
85
|
+
await this.rawRequest('DELETE', `fulfillment/products/${productId}/barcodes/${barcodeId}`);
|
|
86
|
+
}
|
|
72
87
|
async rawRequest(method, action, data) {
|
|
73
88
|
// Set headers
|
|
74
89
|
const headers = {
|
|
@@ -86,7 +101,7 @@ class QlsClient {
|
|
|
86
101
|
if (!response.ok) {
|
|
87
102
|
const errorText = await response.text();
|
|
88
103
|
// Log error including the request body
|
|
89
|
-
core_1.Logger.error(`QLS request failed: ${response.status} ${response.statusText} - ${errorText}`, constants_1.loggerCtx, data ? JSON.stringify(data, null, 2) : undefined);
|
|
104
|
+
core_1.Logger.error(`QLS request to '${url}' failed: ${response.status} ${response.statusText} - ${errorText}`, constants_1.loggerCtx, data ? JSON.stringify(data, null, 2) : undefined);
|
|
90
105
|
throw new Error(errorText);
|
|
91
106
|
}
|
|
92
107
|
const contentType = response.headers.get('content-type') ?? '';
|
|
@@ -133,6 +133,7 @@ let QlsOrderService = class QlsOrderService {
|
|
|
133
133
|
...(additionalOrderFields ?? {}),
|
|
134
134
|
};
|
|
135
135
|
const result = await client.createFulfillmentOrder(qlsOrder);
|
|
136
|
+
core_2.Logger.info(`Successfully created order '${order.code}' in QLS with id '${result.id}'`, constants_1.loggerCtx);
|
|
136
137
|
await this.orderService.addNoteToOrder(ctx, {
|
|
137
138
|
id: orderId,
|
|
138
139
|
isPublic: false,
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { OnApplicationBootstrap, OnModuleInit } from '@nestjs/common';
|
|
2
|
-
import { EventBus, ID, Job, JobQueueService, ProductVariant, ProductVariantService, RequestContext, StockLevelService, StockLocationService, TransactionalConnection } from '@vendure/core';
|
|
2
|
+
import { EventBus, ID, Job, JobQueueService, ListQueryBuilder, ProductPriceApplicator, ProductVariant, ProductVariantService, RequestContext, StockLevelService, StockLocationService, TransactionalConnection } from '@vendure/core';
|
|
3
|
+
import { FulfillmentProduct } from '../lib/client-types';
|
|
3
4
|
import { QlsClient } from '../lib/qls-client';
|
|
4
5
|
import { QlsPluginOptions, QlsProductJobData } from '../types';
|
|
5
|
-
import { FulfillmentProduct } from '../lib/client-types';
|
|
6
6
|
type SyncProductsJobResult = {
|
|
7
7
|
updatedInQls: number;
|
|
8
8
|
createdInQls: number;
|
|
9
9
|
updatedStock: number;
|
|
10
|
+
failed: number;
|
|
10
11
|
};
|
|
11
12
|
export declare class QlsProductService implements OnModuleInit, OnApplicationBootstrap {
|
|
12
13
|
private connection;
|
|
@@ -16,8 +17,10 @@ export declare class QlsProductService implements OnModuleInit, OnApplicationBoo
|
|
|
16
17
|
private readonly variantService;
|
|
17
18
|
private readonly stockLocationService;
|
|
18
19
|
private readonly eventBus;
|
|
20
|
+
private readonly listQueryBuilder;
|
|
21
|
+
private readonly productPriceApplicator;
|
|
19
22
|
private productJobQueue;
|
|
20
|
-
constructor(connection: TransactionalConnection, options: QlsPluginOptions, jobQueueService: JobQueueService, stockLevelService: StockLevelService, variantService: ProductVariantService, stockLocationService: StockLocationService, eventBus: EventBus);
|
|
23
|
+
constructor(connection: TransactionalConnection, options: QlsPluginOptions, jobQueueService: JobQueueService, stockLevelService: StockLevelService, variantService: ProductVariantService, stockLocationService: StockLocationService, eventBus: EventBus, listQueryBuilder: ListQueryBuilder, productPriceApplicator: ProductPriceApplicator);
|
|
21
24
|
onApplicationBootstrap(): void;
|
|
22
25
|
onModuleInit(): Promise<void>;
|
|
23
26
|
/**
|
|
@@ -53,6 +56,10 @@ export declare class QlsProductService implements OnModuleInit, OnApplicationBoo
|
|
|
53
56
|
* Determines if a product needs to be created or updated in QLS based on the given variant and existing QLS product.
|
|
54
57
|
*/
|
|
55
58
|
createOrUpdateProductInQls(ctx: RequestContext, client: QlsClient, variant: ProductVariant, existingProduct: FulfillmentProduct | null): Promise<'created' | 'updated' | 'not-changed'>;
|
|
59
|
+
/**
|
|
60
|
+
* Update the additional EANs/barcodes for a product in QLS if needed
|
|
61
|
+
*/
|
|
62
|
+
private updateAdditionalEANs;
|
|
56
63
|
/**
|
|
57
64
|
* Determine if a product needs to be updated in QLS based on the given variant and QLS product.
|
|
58
65
|
*/
|
|
@@ -19,11 +19,13 @@ exports.QlsProductService = void 0;
|
|
|
19
19
|
const common_1 = require("@nestjs/common");
|
|
20
20
|
const core_1 = require("@vendure/core");
|
|
21
21
|
const catch_unknown_1 = require("catch-unknown");
|
|
22
|
+
const typeorm_1 = require("typeorm");
|
|
22
23
|
const util_1 = __importDefault(require("util"));
|
|
23
24
|
const constants_1 = require("../constants");
|
|
24
25
|
const qls_client_1 = require("../lib/qls-client");
|
|
26
|
+
const util_2 = require("./util");
|
|
25
27
|
let QlsProductService = class QlsProductService {
|
|
26
|
-
constructor(connection, options, jobQueueService, stockLevelService, variantService, stockLocationService, eventBus) {
|
|
28
|
+
constructor(connection, options, jobQueueService, stockLevelService, variantService, stockLocationService, eventBus, listQueryBuilder, productPriceApplicator) {
|
|
27
29
|
this.connection = connection;
|
|
28
30
|
this.options = options;
|
|
29
31
|
this.jobQueueService = jobQueueService;
|
|
@@ -31,6 +33,8 @@ let QlsProductService = class QlsProductService {
|
|
|
31
33
|
this.variantService = variantService;
|
|
32
34
|
this.stockLocationService = stockLocationService;
|
|
33
35
|
this.eventBus = eventBus;
|
|
36
|
+
this.listQueryBuilder = listQueryBuilder;
|
|
37
|
+
this.productPriceApplicator = productPriceApplicator;
|
|
34
38
|
}
|
|
35
39
|
onApplicationBootstrap() {
|
|
36
40
|
// Listen for ProductVariantEvent and add a job to the queue
|
|
@@ -85,6 +89,8 @@ let QlsProductService = class QlsProductService {
|
|
|
85
89
|
* 4. Updates products in QLS if needed
|
|
86
90
|
*/
|
|
87
91
|
async runFullSync(ctx) {
|
|
92
|
+
// Wait for 700ms to avoid rate limit of 500/5 minutes
|
|
93
|
+
const waitToPreventRateLimit = () => new Promise((resolve) => setTimeout(resolve, 700));
|
|
88
94
|
try {
|
|
89
95
|
const client = await (0, qls_client_1.getQlsClient)(ctx, this.options);
|
|
90
96
|
if (!client) {
|
|
@@ -107,22 +113,37 @@ let QlsProductService = class QlsProductService {
|
|
|
107
113
|
// Create or update products in QLS
|
|
108
114
|
let createdQlsProductsCount = 0;
|
|
109
115
|
let updatedQlsProductsCount = 0;
|
|
116
|
+
let failedCount = 0;
|
|
110
117
|
for (const variant of allVariants) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
118
|
+
try {
|
|
119
|
+
const existingQlsProduct = allQlsProducts.find((p) => p.sku == variant.sku);
|
|
120
|
+
const result = await this.createOrUpdateProductInQls(ctx, client, variant, existingQlsProduct ?? null);
|
|
121
|
+
if (result === 'created') {
|
|
122
|
+
createdQlsProductsCount += 1;
|
|
123
|
+
}
|
|
124
|
+
else if (result === 'updated') {
|
|
125
|
+
updatedQlsProductsCount += 1;
|
|
126
|
+
}
|
|
127
|
+
if (result === 'created' || result === 'updated') {
|
|
128
|
+
// Wait only if we created or updated a product, otherwise no calls have been made yet.
|
|
129
|
+
await waitToPreventRateLimit();
|
|
130
|
+
}
|
|
115
131
|
}
|
|
116
|
-
|
|
117
|
-
|
|
132
|
+
catch (e) {
|
|
133
|
+
const error = (0, catch_unknown_1.asError)(e);
|
|
134
|
+
core_1.Logger.error(`Error creating or updating variant '${variant.sku}' in QLS: ${error.message}`, constants_1.loggerCtx, error.stack);
|
|
135
|
+
failedCount += 1;
|
|
136
|
+
await waitToPreventRateLimit();
|
|
118
137
|
}
|
|
119
138
|
}
|
|
120
139
|
core_1.Logger.info(`Created ${createdQlsProductsCount} products in QLS`, constants_1.loggerCtx);
|
|
121
140
|
core_1.Logger.info(`Updated ${updatedQlsProductsCount} products in QLS`, constants_1.loggerCtx);
|
|
141
|
+
core_1.Logger.info(`Finished full sync with ${failedCount} failures`, constants_1.loggerCtx);
|
|
122
142
|
return {
|
|
123
143
|
updatedInQls: updatedQlsProductsCount,
|
|
124
144
|
createdInQls: createdQlsProductsCount,
|
|
125
145
|
updatedStock: updateStockCount,
|
|
146
|
+
failed: failedCount,
|
|
126
147
|
};
|
|
127
148
|
}
|
|
128
149
|
catch (e) {
|
|
@@ -142,29 +163,44 @@ let QlsProductService = class QlsProductService {
|
|
|
142
163
|
updatedInQls: 0,
|
|
143
164
|
createdInQls: 0,
|
|
144
165
|
updatedStock: 0,
|
|
166
|
+
failed: 0,
|
|
145
167
|
};
|
|
146
168
|
}
|
|
147
169
|
let updatedInQls = 0;
|
|
148
170
|
let createdInQls = 0;
|
|
171
|
+
let failedCount = 0;
|
|
149
172
|
for (const variantId of productVariantIds) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
173
|
+
try {
|
|
174
|
+
const variant = await this.variantService.findOne(ctx, variantId, [
|
|
175
|
+
'featuredAsset',
|
|
176
|
+
'taxCategory',
|
|
177
|
+
'channels',
|
|
178
|
+
'product.featuredAsset',
|
|
179
|
+
]);
|
|
180
|
+
if (!variant) {
|
|
181
|
+
core_1.Logger.error(`Variant with id ${variantId} not found. Not creating or updating product in QLS.`, constants_1.loggerCtx);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const existingQlsProduct = await client.getFulfillmentProductBySku(variant.sku);
|
|
185
|
+
const result = await this.createOrUpdateProductInQls(ctx, client, variant, existingQlsProduct ?? null);
|
|
186
|
+
if (result === 'created') {
|
|
187
|
+
createdInQls += 1;
|
|
188
|
+
}
|
|
189
|
+
else if (result === 'updated') {
|
|
190
|
+
updatedInQls += 1;
|
|
191
|
+
}
|
|
159
192
|
}
|
|
160
|
-
|
|
161
|
-
|
|
193
|
+
catch (e) {
|
|
194
|
+
const error = (0, catch_unknown_1.asError)(e);
|
|
195
|
+
core_1.Logger.error(`Error syncing variant ${variantId} to QLS: ${error.message}`, constants_1.loggerCtx, error.stack);
|
|
196
|
+
failedCount += 1;
|
|
162
197
|
}
|
|
163
198
|
}
|
|
164
199
|
return {
|
|
165
200
|
updatedInQls,
|
|
166
201
|
createdInQls,
|
|
167
202
|
updatedStock: 0,
|
|
203
|
+
failed: failedCount,
|
|
168
204
|
};
|
|
169
205
|
}
|
|
170
206
|
/**
|
|
@@ -211,45 +247,88 @@ let QlsProductService = class QlsProductService {
|
|
|
211
247
|
* Determines if a product needs to be created or updated in QLS based on the given variant and existing QLS product.
|
|
212
248
|
*/
|
|
213
249
|
async createOrUpdateProductInQls(ctx, client, variant, existingProduct) {
|
|
214
|
-
let
|
|
250
|
+
let qlsProduct = existingProduct;
|
|
215
251
|
let createdOrUpdated = 'not-changed';
|
|
252
|
+
const { additionalEANs, ...additionalVariantFields } = this.options.getAdditionalVariantFields(ctx, variant);
|
|
216
253
|
if (!existingProduct) {
|
|
217
254
|
const result = await client.createFulfillmentProduct({
|
|
218
255
|
name: variant.name,
|
|
219
256
|
sku: variant.sku,
|
|
220
|
-
...
|
|
257
|
+
...additionalVariantFields,
|
|
221
258
|
});
|
|
222
|
-
|
|
259
|
+
qlsProduct = result;
|
|
223
260
|
core_1.Logger.info(`Created product '${variant.sku}' in QLS`, constants_1.loggerCtx);
|
|
224
261
|
createdOrUpdated = 'created';
|
|
225
262
|
}
|
|
226
|
-
else if (this.shouldUpdateProductInQls(
|
|
263
|
+
else if (this.shouldUpdateProductInQls(variant, existingProduct, additionalVariantFields)) {
|
|
227
264
|
await client.updateFulfillmentProduct(existingProduct.id, {
|
|
228
265
|
sku: variant.sku,
|
|
229
266
|
name: variant.name,
|
|
230
|
-
...
|
|
267
|
+
...additionalVariantFields,
|
|
231
268
|
});
|
|
232
269
|
core_1.Logger.info(`Updated product '${variant.sku}' in QLS`, constants_1.loggerCtx);
|
|
233
270
|
createdOrUpdated = 'updated';
|
|
234
271
|
}
|
|
235
|
-
if (
|
|
272
|
+
if (qlsProduct && qlsProduct?.id !== variant.customFields.qlsProductId) {
|
|
236
273
|
// Update variant with QLS product ID if it changed
|
|
237
274
|
// Do not use variantService.update because it will trigger a change event and cause an infinite loop
|
|
238
275
|
await this.connection
|
|
239
276
|
.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 ${
|
|
277
|
+
.update({ id: variant.id }, { customFields: { qlsProductId: qlsProduct.id } });
|
|
278
|
+
core_1.Logger.info(`Set QLS product ID for variant '${variant.sku}' to ${qlsProduct.id}`, constants_1.loggerCtx);
|
|
279
|
+
}
|
|
280
|
+
if (qlsProduct) {
|
|
281
|
+
const updatedEANs = await this.updateAdditionalEANs(ctx, client, qlsProduct, additionalEANs);
|
|
282
|
+
if (createdOrUpdated === 'not-changed' && updatedEANs) {
|
|
283
|
+
// If nothing changed so far, but EANs changed, then we did update the product in QLS
|
|
284
|
+
createdOrUpdated = 'updated';
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (createdOrUpdated === 'not-changed') {
|
|
288
|
+
core_1.Logger.info(`Variant '${variant.sku}' not updated in QLS, because no changes were found.`, constants_1.loggerCtx);
|
|
242
289
|
}
|
|
243
290
|
return createdOrUpdated;
|
|
244
291
|
}
|
|
292
|
+
/**
|
|
293
|
+
* Update the additional EANs/barcodes for a product in QLS if needed
|
|
294
|
+
*/
|
|
295
|
+
async updateAdditionalEANs(ctx, client, qlsProduct, additionalEANs) {
|
|
296
|
+
const existingAdditionalEANs = qlsProduct?.barcodes_and_ean.filter((ean) => ean !== qlsProduct.ean); // Remove the main EAN
|
|
297
|
+
const eansToUpdate = (0, util_2.getEansToUpdate)({
|
|
298
|
+
existingEans: existingAdditionalEANs,
|
|
299
|
+
desiredEans: additionalEANs,
|
|
300
|
+
});
|
|
301
|
+
if (!eansToUpdate ||
|
|
302
|
+
(eansToUpdate.eansToAdd.length === 0 &&
|
|
303
|
+
eansToUpdate.eansToRemove.length === 0)) {
|
|
304
|
+
// No updates needed
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
// Add additional EANs
|
|
308
|
+
eansToUpdate.eansToAdd = (0, util_2.normalizeEans)(eansToUpdate.eansToAdd);
|
|
309
|
+
await Promise.all(eansToUpdate.eansToAdd.map((ean) => client.addBarcode(qlsProduct.id, ean)));
|
|
310
|
+
if (eansToUpdate.eansToAdd.length > 0) {
|
|
311
|
+
core_1.Logger.info(`Added additional EANs: ${eansToUpdate.eansToAdd.join(',')} to product '${qlsProduct.sku}' in QLS`, constants_1.loggerCtx);
|
|
312
|
+
}
|
|
313
|
+
// Remove EANs, normalize first
|
|
314
|
+
eansToUpdate.eansToRemove = (0, util_2.normalizeEans)(eansToUpdate.eansToRemove);
|
|
315
|
+
// get barcode ID's to remove, because deletion goes via barcode ID, not barcode value
|
|
316
|
+
const barcodeIdsToRemove = qlsProduct.barcodes
|
|
317
|
+
.filter((barcode) => eansToUpdate.eansToRemove.includes(barcode.barcode))
|
|
318
|
+
.map((barcode) => barcode.id);
|
|
319
|
+
await Promise.all(barcodeIdsToRemove.map((barcodeId) => client.removeBarcode(qlsProduct.id, barcodeId)));
|
|
320
|
+
if (eansToUpdate.eansToRemove.length > 0) {
|
|
321
|
+
core_1.Logger.info(`Removed EANs '${eansToUpdate.eansToRemove.join(',')} from product '${qlsProduct.sku}' in QLS`, constants_1.loggerCtx);
|
|
322
|
+
}
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
245
325
|
/**
|
|
246
326
|
* Determine if a product needs to be updated in QLS based on the given variant and QLS product.
|
|
247
327
|
*/
|
|
248
|
-
shouldUpdateProductInQls(
|
|
249
|
-
const additionalFields = this.options.getAdditionalVariantFields?.(ctx, variant);
|
|
328
|
+
shouldUpdateProductInQls(variant, qlsProduct, additionalVariantFields) {
|
|
250
329
|
if (qlsProduct.name !== variant.name ||
|
|
251
|
-
qlsProduct.ean !==
|
|
252
|
-
qlsProduct.image_url !==
|
|
330
|
+
qlsProduct.ean !== additionalVariantFields?.ean ||
|
|
331
|
+
qlsProduct.image_url !== additionalVariantFields?.image_url) {
|
|
253
332
|
// If name or ean has changed, product should be updated in QLS
|
|
254
333
|
return true;
|
|
255
334
|
}
|
|
@@ -264,16 +343,27 @@ let QlsProductService = class QlsProductService {
|
|
|
264
343
|
const take = 100;
|
|
265
344
|
let hasMore = true;
|
|
266
345
|
while (hasMore) {
|
|
267
|
-
const
|
|
268
|
-
|
|
346
|
+
const relations = [
|
|
347
|
+
'featuredAsset',
|
|
348
|
+
'taxCategory',
|
|
349
|
+
'channels',
|
|
350
|
+
'product.featuredAsset',
|
|
351
|
+
];
|
|
352
|
+
const [items, totalItems] = await this.listQueryBuilder
|
|
353
|
+
.build(core_1.ProductVariant, {
|
|
269
354
|
skip,
|
|
270
355
|
take,
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
356
|
+
}, {
|
|
357
|
+
relations,
|
|
358
|
+
channelId: ctx.channelId,
|
|
359
|
+
where: { deletedAt: (0, typeorm_1.IsNull)() },
|
|
360
|
+
ctx,
|
|
361
|
+
})
|
|
362
|
+
.getManyAndCount();
|
|
363
|
+
let variants = await Promise.all(items.map(async (item) => this.productPriceApplicator.applyChannelPriceAndTax(item, ctx, undefined, false)));
|
|
364
|
+
variants = variants.map((v) => (0, core_1.translateDeep)(v, ctx.languageCode));
|
|
365
|
+
allVariants.push(...variants);
|
|
366
|
+
if (allVariants.length >= totalItems) {
|
|
277
367
|
hasMore = false;
|
|
278
368
|
}
|
|
279
369
|
skip += take;
|
|
@@ -284,6 +374,10 @@ let QlsProductService = class QlsProductService {
|
|
|
284
374
|
* Update stock level for a variant based on the given available stock
|
|
285
375
|
*/
|
|
286
376
|
async updateStock(ctx, variantId, availableStock) {
|
|
377
|
+
if (this.options.disableStockSync) {
|
|
378
|
+
core_1.Logger.warn(`Stock sync disabled. Not updating stock for variant '${variantId}'`, constants_1.loggerCtx);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
287
381
|
// Find default Stock Location
|
|
288
382
|
const defaultStockLocation = await this.stockLocationService.defaultStockLocation(ctx);
|
|
289
383
|
// Get current stock level id
|
|
@@ -305,5 +399,7 @@ exports.QlsProductService = QlsProductService = __decorate([
|
|
|
305
399
|
core_1.StockLevelService,
|
|
306
400
|
core_1.ProductVariantService,
|
|
307
401
|
core_1.StockLocationService,
|
|
308
|
-
core_1.EventBus
|
|
402
|
+
core_1.EventBus,
|
|
403
|
+
core_1.ListQueryBuilder,
|
|
404
|
+
core_1.ProductPriceApplicator])
|
|
309
405
|
], QlsProductService);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface EanUpdate {
|
|
2
|
+
eansToAdd: string[];
|
|
3
|
+
eansToRemove: string[];
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Get the EANs to add or remove
|
|
7
|
+
*/
|
|
8
|
+
export declare function getEansToUpdate({ existingEans, desiredEans, }: {
|
|
9
|
+
existingEans?: (string | undefined | null)[];
|
|
10
|
+
desiredEans?: (string | undefined | null)[];
|
|
11
|
+
}): EanUpdate | false;
|
|
12
|
+
/**
|
|
13
|
+
* Normalize the EANs by filtering out null, undefined and empty strings and sorting them
|
|
14
|
+
*/
|
|
15
|
+
export declare function normalizeEans(eans?: (string | undefined | null)[]): string[];
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getEansToUpdate = getEansToUpdate;
|
|
4
|
+
exports.normalizeEans = normalizeEans;
|
|
5
|
+
/**
|
|
6
|
+
* Get the EANs to add or remove
|
|
7
|
+
*/
|
|
8
|
+
function getEansToUpdate({ existingEans, desiredEans, }) {
|
|
9
|
+
const normalizedExisting = normalizeEans(existingEans);
|
|
10
|
+
const normalizedDesired = normalizeEans(desiredEans);
|
|
11
|
+
// Find out what EANs to add
|
|
12
|
+
const eansToAdd = normalizedDesired.filter((ean) => !normalizedExisting.includes(ean));
|
|
13
|
+
// Find out what EANs to remove
|
|
14
|
+
const eansToRemove = normalizedExisting.filter((ean) => !normalizedDesired.includes(ean));
|
|
15
|
+
if (eansToAdd.length === 0 && eansToRemove.length === 0) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
eansToAdd,
|
|
20
|
+
eansToRemove,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Normalize the EANs by filtering out null, undefined and empty strings and sorting them
|
|
25
|
+
*/
|
|
26
|
+
function normalizeEans(eans) {
|
|
27
|
+
return (eans ?? [])
|
|
28
|
+
.filter((ean) => !!ean && ean.trim().length > 0)
|
|
29
|
+
.map((ean) => ean.trim())
|
|
30
|
+
.sort();
|
|
31
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const vitest_1 = require("vitest");
|
|
4
|
+
const util_1 = require("./util");
|
|
5
|
+
(0, vitest_1.describe)('getEansToUpdate', () => {
|
|
6
|
+
(0, vitest_1.it)('returns false for identical EAN arrays', () => {
|
|
7
|
+
(0, vitest_1.expect)((0, util_1.getEansToUpdate)({
|
|
8
|
+
existingEans: ['123', '456'],
|
|
9
|
+
desiredEans: ['456', '123'],
|
|
10
|
+
})).toBe(false);
|
|
11
|
+
});
|
|
12
|
+
(0, vitest_1.it)('ignores ordering, whitespace, null/undefined and empty stringswhen determining additions', () => {
|
|
13
|
+
(0, vitest_1.expect)((0, util_1.getEansToUpdate)({
|
|
14
|
+
existingEans: [null, '123', ''],
|
|
15
|
+
desiredEans: [' 123', '456 ', undefined, null],
|
|
16
|
+
})).toEqual({
|
|
17
|
+
eansToAdd: ['456'],
|
|
18
|
+
eansToRemove: [],
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
(0, vitest_1.it)('returns removals when desired array is missing values', () => {
|
|
22
|
+
(0, vitest_1.expect)((0, util_1.getEansToUpdate)({
|
|
23
|
+
existingEans: ['123', '456', null],
|
|
24
|
+
desiredEans: ['123'],
|
|
25
|
+
})).toEqual({
|
|
26
|
+
eansToAdd: [],
|
|
27
|
+
eansToRemove: ['456'],
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
(0, vitest_1.it)('returns both additions and removals when values differ', () => {
|
|
31
|
+
(0, vitest_1.expect)((0, util_1.getEansToUpdate)({
|
|
32
|
+
existingEans: ['123', '456'],
|
|
33
|
+
desiredEans: ['123', '789'],
|
|
34
|
+
})).toEqual({
|
|
35
|
+
eansToAdd: ['789'],
|
|
36
|
+
eansToRemove: ['456'],
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
(0, vitest_1.it)('treats undefined arrays as equal', () => {
|
|
40
|
+
(0, vitest_1.expect)((0, util_1.getEansToUpdate)({ existingEans: undefined, desiredEans: undefined })).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
(0, vitest_1.it)('treats undefined and empty array as equal', () => {
|
|
43
|
+
// This is the case when creating a new product in QLS
|
|
44
|
+
(0, vitest_1.expect)((0, util_1.getEansToUpdate)({ existingEans: undefined, desiredEans: [] })).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
(0, vitest_1.describe)('normalizeEans', () => {
|
|
48
|
+
(0, vitest_1.it)('filters and trims invalid values', () => {
|
|
49
|
+
(0, vitest_1.expect)((0, util_1.normalizeEans)([' 123', '', undefined, null, '456 '])).toEqual([
|
|
50
|
+
'123',
|
|
51
|
+
'456',
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
(0, vitest_1.it)('returns empty array for undefined input', () => {
|
|
55
|
+
(0, vitest_1.expect)((0, util_1.normalizeEans)(undefined)).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
(0, vitest_1.it)('sorts resulting values alphabetically', () => {
|
|
58
|
+
(0, vitest_1.expect)((0, util_1.normalizeEans)(['789', '123'])).toEqual(['123', '789']);
|
|
59
|
+
});
|
|
60
|
+
});
|
package/dist/types.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export interface QlsPluginOptions {
|
|
|
9
9
|
* Required to get the EAN and image URL for a product variant.
|
|
10
10
|
* Also allows you to override other product attributes like name, price, etc.
|
|
11
11
|
*/
|
|
12
|
-
getAdditionalVariantFields
|
|
12
|
+
getAdditionalVariantFields: (ctx: RequestContext, variant: ProductVariant) => AdditionalVariantFields;
|
|
13
13
|
/**
|
|
14
14
|
* Function to get the set service point code for an order.
|
|
15
15
|
* Return undefined to not use a service point at all.
|
|
@@ -20,7 +20,19 @@ export interface QlsPluginOptions {
|
|
|
20
20
|
* Set this to a random string
|
|
21
21
|
*/
|
|
22
22
|
webhookSecret: string;
|
|
23
|
+
/**
|
|
24
|
+
* Disable the pulling in of stock levels from QLS. When disabled, stock in Vendure will not be modified based on QLS stock levels.
|
|
25
|
+
* Useful for testing out order sync separately, or testing against a QLS test env that has no stock for example
|
|
26
|
+
*/
|
|
27
|
+
disableStockSync?: boolean;
|
|
23
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Additional fields for a product variant that are used to create or update a product in QLS
|
|
31
|
+
*/
|
|
32
|
+
export type AdditionalVariantFields = Partial<FulfillmentProductInput & {
|
|
33
|
+
ean: string;
|
|
34
|
+
additionalEANs?: string[];
|
|
35
|
+
}>;
|
|
24
36
|
export interface AdditionalOrderFields {
|
|
25
37
|
servicepoint_code?: string;
|
|
26
38
|
delivery_options?: string[];
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { addActionBarDropdownMenuItem } from '@vendure/admin-ui/core';
|
|
2
|
+
import gql from 'graphql-tag';
|
|
3
|
+
|
|
4
|
+
export default [
|
|
5
|
+
// Product sync button in product list
|
|
6
|
+
addActionBarDropdownMenuItem({
|
|
7
|
+
id: 'qls-fulfillment-sync',
|
|
8
|
+
label: 'Synchronize with QLS',
|
|
9
|
+
locationId: 'product-list',
|
|
10
|
+
icon: 'resistor',
|
|
11
|
+
requiresPermission: ['QLSFullSync'],
|
|
12
|
+
onClick: (_, { dataService, notificationService }) => {
|
|
13
|
+
dataService
|
|
14
|
+
.mutate(
|
|
15
|
+
gql`
|
|
16
|
+
mutation TriggerQlsProductSync {
|
|
17
|
+
triggerQlsProductSync
|
|
18
|
+
}
|
|
19
|
+
`
|
|
20
|
+
)
|
|
21
|
+
.subscribe({
|
|
22
|
+
next: () => {
|
|
23
|
+
notificationService.notify({
|
|
24
|
+
message: `Triggered QLS full product sync...`,
|
|
25
|
+
type: 'success',
|
|
26
|
+
});
|
|
27
|
+
},
|
|
28
|
+
error: (err) => {
|
|
29
|
+
notificationService.notify({
|
|
30
|
+
message: err.message,
|
|
31
|
+
type: 'error',
|
|
32
|
+
});
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
// Push order to QLS button on order detail page
|
|
38
|
+
addActionBarDropdownMenuItem({
|
|
39
|
+
id: 'qls-fulfillment-push-order',
|
|
40
|
+
label: 'Push order to QLS',
|
|
41
|
+
locationId: 'order-detail',
|
|
42
|
+
icon: 'resistor',
|
|
43
|
+
requiresPermission: ['QLSFullSync'],
|
|
44
|
+
hasDivider: true,
|
|
45
|
+
onClick: (_, { route, dataService, notificationService }) => {
|
|
46
|
+
dataService
|
|
47
|
+
.mutate(
|
|
48
|
+
gql`
|
|
49
|
+
mutation PushOrderToQls($orderId: ID!) {
|
|
50
|
+
pushOrderToQls(orderId: $orderId)
|
|
51
|
+
}
|
|
52
|
+
`,
|
|
53
|
+
{
|
|
54
|
+
orderId: route.snapshot.params.id,
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
.subscribe({
|
|
58
|
+
next: (result) => {
|
|
59
|
+
console.log(result);
|
|
60
|
+
notificationService.notify({
|
|
61
|
+
message: (result as any).pushOrderToQls,
|
|
62
|
+
type: 'success',
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
error: (err) => {
|
|
66
|
+
notificationService.notify({
|
|
67
|
+
message: err.message,
|
|
68
|
+
type: 'error',
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pinelab/vendure-plugin-qls-fulfillment",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.11",
|
|
4
4
|
"description": "Vendure plugin to fulfill orders via QLS.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"fulfillment",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"CHANGELOG.md"
|
|
24
24
|
],
|
|
25
25
|
"scripts": {
|
|
26
|
-
"build": "rimraf dist && yarn generate && tsc",
|
|
26
|
+
"build": "rimraf dist && yarn generate && tsc && copyfiles -u 1 'src/ui/**/*' dist/",
|
|
27
27
|
"start": "nodemon --watch src --exec ts-node test/dev-server.ts",
|
|
28
28
|
"generate": "graphql-codegen",
|
|
29
29
|
"test": "vitest run --bail 1",
|