@pinelab/vendure-plugin-qls-fulfillment 2.1.0 → 2.2.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ # 2.2.1 (2026-07-15)
2
+
3
+ - Emit `StockMovementEvent` with corresponding `StockAdjustment` whenever a QLS webhook updates stock of a variant.
4
+
5
+ # 2.2.0 (2026-07-15)
6
+
7
+ - Added buttons on variant and order detail to view the product and order in QLS dashboard.
8
+
1
9
  # 2.1.0 (2026-07-08)
2
10
 
3
11
  - Added `addAdditionalEANSToQLS` admin mutation to manually add additional EANs/barcodes to existing QLS products.
@@ -11,6 +11,18 @@ exports.adminApiExtensions = (0, graphql_tag_1.default) `
11
11
  QLS order id(s) for this Vendure order.
12
12
  """
13
13
  qlsOrderIds: [String!]!
14
+
15
+ """
16
+ Direct URL to the most recent QLS order for this Vendure order, or null if none exists.
17
+ """
18
+ qlsOrderUrl: String
19
+ }
20
+
21
+ extend type ProductVariant {
22
+ """
23
+ Direct URL to the QLS product for this Vendure variant, or null if none exists.
24
+ """
25
+ qlsProductUrl: String
14
26
  }
15
27
 
16
28
  extend type Mutation {
@@ -42,6 +42,13 @@ export type Order = {
42
42
  __typename?: 'Order';
43
43
  /** QLS order id(s) for this Vendure order. */
44
44
  qlsOrderIds: Array<Scalars['String']>;
45
+ /** Direct URL to the most recent QLS order for this Vendure order, or null if none exists. */
46
+ qlsOrderUrl?: Maybe<Scalars['String']>;
47
+ };
48
+ export type ProductVariant = {
49
+ __typename?: 'ProductVariant';
50
+ /** Direct URL to the QLS product for this Vendure variant, or null if none exists. */
51
+ qlsProductUrl?: Maybe<Scalars['String']>;
45
52
  };
46
53
  export type QlsServicePoint = {
47
54
  __typename?: 'QlsServicePoint';
@@ -1,4 +1,4 @@
1
- import { Order, RequestContext } from '@vendure/core';
1
+ import { Order, ProductVariant, RequestContext } from '@vendure/core';
2
2
  import { QlsOrderService } from '../services/qls-order.service';
3
3
  import { QlsProductService } from '../services/qls-product.service';
4
4
  import { MutationAddAdditionalEansToQlsArgs, MutationPushOrderToQlsArgs } from './generated/graphql';
@@ -7,6 +7,8 @@ export declare class QlsAdminResolver {
7
7
  private qlsOrderService;
8
8
  constructor(qlsProductService: QlsProductService, qlsOrderService: QlsOrderService);
9
9
  qlsOrderIds(ctx: RequestContext, order: Order): Promise<string[]>;
10
+ qlsOrderUrl(ctx: RequestContext, order: Order): Promise<string | null>;
11
+ qlsProductUrl(ctx: RequestContext, productVariant: ProductVariant): Promise<string | null>;
10
12
  triggerQlsProductSync(ctx: RequestContext): Promise<boolean>;
11
13
  addAdditionalEANSToQLS(ctx: RequestContext, input: MutationAddAdditionalEansToQlsArgs): Promise<string[]>;
12
14
  pushOrderToQls(ctx: RequestContext, input: MutationPushOrderToQlsArgs): Promise<string>;
@@ -26,6 +26,12 @@ let QlsAdminResolver = class QlsAdminResolver {
26
26
  async qlsOrderIds(ctx, order) {
27
27
  return this.qlsOrderService.getQlsOrderIdsForOrder(ctx, order.id);
28
28
  }
29
+ async qlsOrderUrl(ctx, order) {
30
+ return this.qlsOrderService.getQlsOrderUrl(ctx, order.id);
31
+ }
32
+ async qlsProductUrl(ctx, productVariant) {
33
+ return this.qlsProductService.getQlsProductUrl(ctx, productVariant);
34
+ }
29
35
  async triggerQlsProductSync(ctx) {
30
36
  await this.qlsProductService.triggerFullSync(ctx);
31
37
  return true;
@@ -50,6 +56,28 @@ __decorate([
50
56
  core_1.Order]),
51
57
  __metadata("design:returntype", Promise)
52
58
  ], QlsAdminResolver.prototype, "qlsOrderIds", null);
59
+ __decorate([
60
+ (0, graphql_1.ResolveField)(),
61
+ (0, graphql_1.Resolver)('Order'),
62
+ (0, core_1.Allow)(permissions_1.qlsPushOrderPermission.Permission),
63
+ (0, core_1.Allow)(core_1.Permission.UpdateAdministrator),
64
+ __param(0, (0, core_1.Ctx)()),
65
+ __param(1, (0, graphql_1.Parent)()),
66
+ __metadata("design:type", Function),
67
+ __metadata("design:paramtypes", [core_1.RequestContext,
68
+ core_1.Order]),
69
+ __metadata("design:returntype", Promise)
70
+ ], QlsAdminResolver.prototype, "qlsOrderUrl", null);
71
+ __decorate([
72
+ (0, graphql_1.ResolveField)(),
73
+ (0, graphql_1.Resolver)('ProductVariant'),
74
+ __param(0, (0, core_1.Ctx)()),
75
+ __param(1, (0, graphql_1.Parent)()),
76
+ __metadata("design:type", Function),
77
+ __metadata("design:paramtypes", [core_1.RequestContext,
78
+ core_1.ProductVariant]),
79
+ __metadata("design:returntype", Promise)
80
+ ], QlsAdminResolver.prototype, "qlsProductUrl", null);
53
81
  __decorate([
54
82
  (0, graphql_1.Mutation)(),
55
83
  (0, core_1.Transaction)(),
@@ -36,6 +36,11 @@ export declare class QlsOrderService implements OnModuleInit, OnApplicationBoots
36
36
  * Get QLS order id(s) for a Vendure order (for Order.qlsOrderIds field).
37
37
  */
38
38
  getQlsOrderIdsForOrder(ctx: RequestContext, orderId: ID): Promise<string[]>;
39
+ /**
40
+ * Get the direct URL to the most recent QLS order for a Vendure order (for Order.qlsOrderUrl field).
41
+ * Returns the URL or null if no QLS order exists.
42
+ */
43
+ getQlsOrderUrl(ctx: RequestContext, orderId: ID): Promise<string | null>;
39
44
  getServicePoints(ctx: RequestContext, input: QlsServicePointSearchInput): Promise<QlsServicePoint[]>;
40
45
  private getVendureOrderState;
41
46
  }
@@ -281,9 +281,30 @@ let QlsOrderService = class QlsOrderService {
281
281
  .getRepository(ctx, qls_order_entity_entity_1.QlsOrderEntity)
282
282
  .find({
283
283
  where: { vendureOrderId: orderId },
284
+ order: { createdAt: 'DESC' },
284
285
  });
285
286
  return entities.map((e) => String(e.qlsOrderId));
286
287
  }
288
+ /**
289
+ * Get the direct URL to the most recent QLS order for a Vendure order (for Order.qlsOrderUrl field).
290
+ * Returns the URL or null if no QLS order exists.
291
+ */
292
+ async getQlsOrderUrl(ctx, orderId) {
293
+ const entities = await this.connection
294
+ .getRepository(ctx, qls_order_entity_entity_1.QlsOrderEntity)
295
+ .find({
296
+ where: { vendureOrderId: orderId },
297
+ order: { createdAt: 'DESC' },
298
+ });
299
+ if (entities.length === 0) {
300
+ return null;
301
+ }
302
+ const config = await Promise.resolve(this.options.getConfig(ctx));
303
+ if (!config) {
304
+ return null;
305
+ }
306
+ return `https://mijn.pakketdienstqls.nl/company/${config.companyId}/fulfillment/order/${entities[0].qlsOrderId}`;
307
+ }
287
308
  async getServicePoints(ctx, input) {
288
309
  const client = await (0, qls_client_1.getQlsClient)(ctx, this.options);
289
310
  if (!client) {
@@ -55,15 +55,21 @@ export declare class QlsProductService implements OnModuleInit, OnApplicationBoo
55
55
  * Trigger a product sync job for particular product variants
56
56
  */
57
57
  triggerSyncVariants(ctx: RequestContext, productVariantIds: ID[]): Promise<import("@vendure/core/dist/job-queue/subscribable-job").SubscribableJob<QlsProductJobData> | undefined>;
58
+ /**
59
+ * Get the direct URL to the QLS product for a Vendure product variant.
60
+ * Returns the URL or null if no QLS product exists.
61
+ */
62
+ getQlsProductUrl(ctx: RequestContext, productVariant: ProductVariant): Promise<string | null>;
58
63
  /**
59
64
  * Add additional EANs/barcodes to an existing QLS product.
60
65
  * Removal of EANs must be done in QLS itself.
61
66
  */
62
67
  addAdditionalEANsToQls(ctx: RequestContext, variantId: ID, additionalEANs: string[]): Promise<string[]>;
63
68
  /**
64
- * Update the stock level for a variant based on the given available stock
69
+ * Update the stock level for a variant based on the given available stock.
70
+ * Returns true if the stock changed
65
71
  */
66
- updateStockBySku(ctx: RequestContext, sku: string, availableStock: number): Promise<void>;
72
+ updateStockBySku(ctx: RequestContext, sku: string, availableStock: number): Promise<boolean>;
67
73
  /**
68
74
  * Determines if a product needs to be created or updated in QLS based on the given variant and existing QLS product.
69
75
  */
@@ -80,7 +86,8 @@ export declare class QlsProductService implements OnModuleInit, OnApplicationBoo
80
86
  */
81
87
  private getAllVariants;
82
88
  /**
83
- * Update stock level for a variant based on the given available stock
89
+ * Update stock level for a variant based on the given available stock.
90
+ * Returns true if the stock changed.
84
91
  */
85
92
  private updateStock;
86
93
  }
@@ -269,6 +269,21 @@ let QlsProductService = class QlsProductService {
269
269
  productVariantIds,
270
270
  }, { retries: 1 });
271
271
  }
272
+ /**
273
+ * Get the direct URL to the QLS product for a Vendure product variant.
274
+ * Returns the URL or null if no QLS product exists.
275
+ */
276
+ async getQlsProductUrl(ctx, productVariant) {
277
+ const qlsProductId = productVariant.customFields?.qlsProductId;
278
+ if (!qlsProductId) {
279
+ return null;
280
+ }
281
+ const config = await Promise.resolve(this.options.getConfig(ctx));
282
+ if (!config) {
283
+ return null;
284
+ }
285
+ return `https://mijn.pakketdienstqls.nl/company/${config.companyId}/fulfillment/product/${qlsProductId}`;
286
+ }
272
287
  /**
273
288
  * Add additional EANs/barcodes to an existing QLS product.
274
289
  * Removal of EANs must be done in QLS itself.
@@ -297,20 +312,22 @@ let QlsProductService = class QlsProductService {
297
312
  return eansToAdd;
298
313
  }
299
314
  /**
300
- * Update the stock level for a variant based on the given available stock
315
+ * Update the stock level for a variant based on the given available stock.
316
+ * Returns true if the stock changed
301
317
  */
302
318
  async updateStockBySku(ctx, sku, availableStock) {
303
319
  const result = await this.variantService.findAll(ctx, {
304
320
  filter: { sku: { eq: sku } },
305
321
  });
306
322
  if (!result.items.length) {
307
- return core_1.Logger.info(`Variant with sku '${sku}' not found, not updating stock`, constants_1.loggerCtx);
323
+ core_1.Logger.info(`Variant with sku '${sku}' not found, not updating stock`, constants_1.loggerCtx);
324
+ return false;
308
325
  }
309
326
  const variant = result.items[0];
310
327
  if (result.items.length > 1) {
311
328
  core_1.Logger.error(`Multiple variants found for sku '${sku}', using '${variant.id}'`, constants_1.loggerCtx);
312
329
  }
313
- await this.updateStock(ctx, variant.id, availableStock);
330
+ return await this.updateStock(ctx, variant.id, availableStock);
314
331
  }
315
332
  /**
316
333
  * Determines if a product needs to be created or updated in QLS based on the given variant and existing QLS product.
@@ -416,24 +433,43 @@ let QlsProductService = class QlsProductService {
416
433
  return allVariants;
417
434
  }
418
435
  /**
419
- * Update stock level for a variant based on the given available stock
436
+ * Update stock level for a variant based on the given available stock.
437
+ * Returns true if the stock changed.
420
438
  */
421
439
  async updateStock(ctx, variantId, availableStock) {
422
440
  if (!this.options.productSync.synchronizeStockLevels) {
423
441
  core_1.Logger.warn(`Stock sync disabled. Not updating stock for variant '${variantId}'`, constants_1.loggerCtx);
424
- return;
442
+ return false;
425
443
  }
426
444
  // Find default Stock Location
427
445
  const defaultStockLocation = await this.stockLocationService.defaultStockLocation(ctx);
428
- // Get current stock level id
429
- const { id: stockLevelId } = await this.stockLevelService.getStockLevel(ctx, variantId, defaultStockLocation.id);
430
- // Update stock level
431
- await this.connection.getRepository(ctx, core_1.StockLevel).save({
432
- id: stockLevelId,
433
- stockOnHand: availableStock,
434
- stockAllocated: 0, // Reset allocations, because allocation is handled by QLS
435
- });
446
+ // Get current stock level
447
+ const stockLevel = await this.stockLevelService.getStockLevel(ctx, variantId, defaultStockLocation.id);
448
+ const delta = availableStock - stockLevel.stockOnHand;
449
+ if (delta !== 0) {
450
+ const adjustment = new core_1.StockAdjustment({
451
+ quantity: delta,
452
+ stockLocation: { id: defaultStockLocation.id },
453
+ productVariant: { id: variantId },
454
+ });
455
+ // Update stock level
456
+ await this.connection.getRepository(ctx, core_1.StockLevel).save({
457
+ id: stockLevel.id,
458
+ stockOnHand: availableStock,
459
+ stockAllocated: 0, // Reset allocations, because allocation is handled by QLS
460
+ });
461
+ await this.eventBus.publish(new core_1.StockMovementEvent(ctx, [adjustment]));
462
+ }
463
+ else {
464
+ // No stock change, but still reset allocations to ensure consistency
465
+ await this.connection.getRepository(ctx, core_1.StockLevel).save({
466
+ id: stockLevel.id,
467
+ stockOnHand: availableStock,
468
+ stockAllocated: 0, // Reset allocations, because allocation is handled by QLS
469
+ });
470
+ }
436
471
  core_1.Logger.info(`Updated stock for variant ${variantId} to ${availableStock}`, constants_1.loggerCtx);
472
+ return delta !== 0;
437
473
  }
438
474
  };
439
475
  exports.QlsProductService = QlsProductService;
@@ -1,8 +1,10 @@
1
1
  import {
2
2
  addActionBarDropdownMenuItem,
3
+ addActionBarItem,
3
4
  ModalService,
4
5
  } from '@vendure/admin-ui/core';
5
- import { firstValueFrom } from 'rxjs';
6
+ import { firstValueFrom, of } from 'rxjs';
7
+ import { map, catchError } from 'rxjs/operators';
6
8
  import gql from 'graphql-tag';
7
9
 
8
10
  export default [
@@ -38,6 +40,82 @@ export default [
38
40
  });
39
41
  },
40
42
  }),
43
+ // QLS order link button on order detail page
44
+ addActionBarItem({
45
+ id: 'qls-visit-order',
46
+ label: 'QLS',
47
+ locationId: 'order-detail',
48
+ buttonColor: 'primary',
49
+ buttonStyle: 'outline',
50
+ icon: 'link',
51
+ buttonState: (context) => {
52
+ return context.entity$.pipe(
53
+ map((entity) => ({
54
+ visible: !!entity?.orderPlacedAt,
55
+ disabled: false,
56
+ }))
57
+ );
58
+ },
59
+ onClick: async (event, { route, dataService, notificationService }) => {
60
+ const orderId = route.snapshot.params.id;
61
+ const res = await firstValueFrom(
62
+ dataService.query(
63
+ gql`
64
+ query Order($orderId: ID!) {
65
+ order(id: $orderId) {
66
+ id
67
+ qlsOrderUrl
68
+ }
69
+ }
70
+ `,
71
+ { orderId }
72
+ ).single$
73
+ );
74
+ const url = (res as any).order.qlsOrderUrl;
75
+ if (url) {
76
+ window.open(url, '_blank');
77
+ } else {
78
+ notificationService.notify({
79
+ message: 'No QLS order found for this order.',
80
+ type: 'error',
81
+ });
82
+ }
83
+ },
84
+ }),
85
+ // QLS product link button on product variant detail page
86
+ addActionBarItem({
87
+ id: 'qls-visit-product',
88
+ label: 'QLS',
89
+ locationId: 'product-variant-detail',
90
+ buttonColor: 'primary',
91
+ buttonStyle: 'outline',
92
+ icon: 'link',
93
+ onClick: async (event, { route, dataService, notificationService }) => {
94
+ const variantId = route.snapshot.params.id;
95
+ const res = await firstValueFrom(
96
+ dataService.query(
97
+ gql`
98
+ query ProductVariant($id: ID!) {
99
+ productVariant(id: $id) {
100
+ id
101
+ qlsProductUrl
102
+ }
103
+ }
104
+ `,
105
+ { id: variantId }
106
+ ).single$
107
+ );
108
+ const url = (res as any).productVariant.qlsProductUrl;
109
+ if (url) {
110
+ window.open(url, '_blank');
111
+ } else {
112
+ notificationService.notify({
113
+ message: 'No QLS product found for this variant.',
114
+ type: 'error',
115
+ });
116
+ }
117
+ },
118
+ }),
41
119
  // Push order to QLS button on order detail page
42
120
  addActionBarDropdownMenuItem({
43
121
  id: 'qls-fulfillment-push-order',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pinelab/vendure-plugin-qls-fulfillment",
3
- "version": "2.1.0",
3
+ "version": "2.2.1",
4
4
  "description": "Vendure plugin to fulfill orders via QLS.",
5
5
  "keywords": [
6
6
  "fulfillment",