@pinelab/vendure-plugin-qls-fulfillment 1.0.0-beta.9 → 1.1.0

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,9 @@
1
- # 1.0.0 (2025-11-07) ## CHANGE THIS DATE AND VERSION
1
+ # 1.1.0 (2026-01-07)
2
+
3
+ - Fix delivery options to be an array of objects with a tag property
4
+ - Emit event for failed order push
5
+ - Delay 10s before setting custom field `syncedToQls` to prevent race conditions.
6
+
7
+ # 1.0.0 (2025-11-07)
2
8
 
3
9
  - Initial release
package/README.md CHANGED
@@ -51,20 +51,57 @@ This plugin only uses Vendure's default stock location, that means you should ei
51
51
 
52
52
  Vendure assumes the first created stock location is the default stock location.
53
53
 
54
- ## Monitoring
55
-
56
- Make sure to monitor failed jobs: A job that failed after its retries were exhausted, means:
57
-
58
- 1. An order was not pushed to QLS
59
- 2. A product was not synced to QLS
60
-
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.
67
-
68
- ## Cancelling orders and manually pushing orders to QLS
69
-
70
- // TODO: Push will just create a new order in QLS, it will not cancel the existing order in QLS. Cancel existing order first via https://mijn.pakketdienstqls.nl/
54
+ ## Service Points
55
+
56
+ You can use the query `qlsServicePoints(postalCode: String!): [QlsServicePoint!]!` to get the service points for a given postal code. You can use the `setOrderCustomFields` mutation to set the service point on an order.
57
+
58
+ ```graphql
59
+ mutation {
60
+ setOrderCustomFields(
61
+ input: {
62
+ customFields: {
63
+ qlsServicePointId: "12232" # This is the ID of one of the points returned by the query above
64
+ qlsServicePointDetails: "Some details about the service point for admin users" # This is just for admin users in Vendure
65
+ }
66
+ }
67
+ ) {
68
+ __typename
69
+ ... on Order {
70
+ id
71
+ code
72
+ customFields {
73
+ qlsServicePointId
74
+ qlsServicePointDetails
75
+ }
76
+ }
77
+ }
78
+ }
79
+ ```
80
+
81
+ ## Monitoring failed orders
82
+
83
+ Whenever an order fails to be pushed to QLS, an event is emitted. You can listen to this event to monitor failed orders.
84
+
85
+ ```ts
86
+ this.eventBus.ofType(QlsOrderFailedEvent).subscribe((event) => {
87
+ console.log('Order failed to be pushed to QLS:', event.order.code);
88
+ });
89
+ ```
90
+
91
+ Because a job can be retried, this event can be emitted multiple times for the same order. You can use the date field together with the order code to determine if you have already processed this event.
92
+
93
+ ```ts
94
+ this.eventBus.ofType(QlsOrderFailedEvent).subscribe((event) => {
95
+ // Determine if we have already processed this event.
96
+ const uniqueEventId = `${event.order.code}_${
97
+ event.failedAt.toISOString().split('T')[0]
98
+ }`; // "JHD82JS8868_2026-01-01"
99
+ console.log('Order failed to be pushed to QLS:', event.order.code);
100
+ });
101
+ ```
102
+
103
+ ## Manually pushing orders to QLS
104
+
105
+ This plugin adds a button `push to QLS` to the order detail page in the Admin UI. This will push the order to QLS again. If the order has been pushed before, you need to uncheck the checkbox `synced to QLS` in the order custom fields first.
106
+
107
+ Pushing an order to QLS again will not cancel the existing order in QLS. Cancel existing orders first via https://mijn.pakketdienstqls.nl/.
@@ -1 +1,2 @@
1
1
  export declare const adminApiExtensions: import("graphql").DocumentNode;
2
+ export declare const shopApiExtensions: import("graphql").DocumentNode;
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.adminApiExtensions = void 0;
6
+ exports.shopApiExtensions = exports.adminApiExtensions = void 0;
7
7
  const graphql_tag_1 = __importDefault(require("graphql-tag"));
8
8
  exports.adminApiExtensions = (0, graphql_tag_1.default) `
9
9
  extend type Mutation {
@@ -18,3 +18,47 @@ exports.adminApiExtensions = (0, graphql_tag_1.default) `
18
18
  pushOrderToQls(orderId: ID!): String!
19
19
  }
20
20
  `;
21
+ exports.shopApiExtensions = (0, graphql_tag_1.default) `
22
+ type QlsServicePoint {
23
+ servicepoint_code: String!
24
+ name: String!
25
+ address: QlsServicePointAddress!
26
+ geo: QlsServicePointGeo!
27
+ times: [QlsServicePointTime!]!
28
+ needsPostNumber: Boolean!
29
+ productId: Int!
30
+ productName: String!
31
+ }
32
+
33
+ type QlsServicePointAddress {
34
+ country: String!
35
+ postalcode: String!
36
+ locality: String!
37
+ street: String!
38
+ housenumber: String!
39
+ }
40
+
41
+ type QlsServicePointGeo {
42
+ lat: Float!
43
+ long: Float!
44
+ }
45
+
46
+ type QlsServicePointTime {
47
+ weekday: Int!
48
+ formatted: String!
49
+ from: String!
50
+ to: String!
51
+ }
52
+
53
+ input QlsServicePointSearchInput {
54
+ countryCode: String!
55
+ postalCode: String!
56
+ }
57
+
58
+ extend type Query {
59
+ """
60
+ Get the service points for a given postal code
61
+ """
62
+ qlsServicePoints(input: QlsServicePointSearchInput!): [QlsServicePoint!]!
63
+ }
64
+ `;
@@ -29,3 +29,46 @@ export type Mutation = {
29
29
  export type MutationPushOrderToQlsArgs = {
30
30
  orderId: Scalars['ID'];
31
31
  };
32
+ export type QlsServicePoint = {
33
+ __typename?: 'QlsServicePoint';
34
+ address: QlsServicePointAddress;
35
+ geo: QlsServicePointGeo;
36
+ name: Scalars['String'];
37
+ needsPostNumber: Scalars['Boolean'];
38
+ productId: Scalars['Int'];
39
+ productName: Scalars['String'];
40
+ servicepoint_code: Scalars['String'];
41
+ times: Array<QlsServicePointTime>;
42
+ };
43
+ export type QlsServicePointAddress = {
44
+ __typename?: 'QlsServicePointAddress';
45
+ country: Scalars['String'];
46
+ housenumber: Scalars['String'];
47
+ locality: Scalars['String'];
48
+ postalcode: Scalars['String'];
49
+ street: Scalars['String'];
50
+ };
51
+ export type QlsServicePointGeo = {
52
+ __typename?: 'QlsServicePointGeo';
53
+ lat: Scalars['Float'];
54
+ long: Scalars['Float'];
55
+ };
56
+ export type QlsServicePointSearchInput = {
57
+ countryCode: Scalars['String'];
58
+ postalCode: Scalars['String'];
59
+ };
60
+ export type QlsServicePointTime = {
61
+ __typename?: 'QlsServicePointTime';
62
+ formatted: Scalars['String'];
63
+ from: Scalars['String'];
64
+ to: Scalars['String'];
65
+ weekday: Scalars['Int'];
66
+ };
67
+ export type Query = {
68
+ __typename?: 'Query';
69
+ /** Get the service points for a given postal code */
70
+ qlsServicePoints: Array<QlsServicePoint>;
71
+ };
72
+ export type QueryQlsServicePointsArgs = {
73
+ input: QlsServicePointSearchInput;
74
+ };
@@ -3,9 +3,9 @@ import { QlsProductService } from '../services/qls-product.service';
3
3
  import { MutationPushOrderToQlsArgs } from './generated/graphql';
4
4
  import { QlsOrderService } from '../services/qls-order.service';
5
5
  export declare class QlsAdminResolver {
6
- private qlsService;
6
+ private qlsProductService;
7
7
  private qlsOrderService;
8
- constructor(qlsService: QlsProductService, qlsOrderService: QlsOrderService);
8
+ constructor(qlsProductService: QlsProductService, qlsOrderService: QlsOrderService);
9
9
  triggerQlsProductSync(ctx: RequestContext): Promise<boolean>;
10
10
  pushOrderToQls(ctx: RequestContext, input: MutationPushOrderToQlsArgs): Promise<string>;
11
11
  }
@@ -19,12 +19,12 @@ const qls_product_service_1 = require("../services/qls-product.service");
19
19
  const qls_order_service_1 = require("../services/qls-order.service");
20
20
  const permissions_1 = require("../config/permissions");
21
21
  let QlsAdminResolver = class QlsAdminResolver {
22
- constructor(qlsService, qlsOrderService) {
23
- this.qlsService = qlsService;
22
+ constructor(qlsProductService, qlsOrderService) {
23
+ this.qlsProductService = qlsProductService;
24
24
  this.qlsOrderService = qlsOrderService;
25
25
  }
26
26
  async triggerQlsProductSync(ctx) {
27
- await this.qlsService.triggerFullSync(ctx);
27
+ await this.qlsProductService.triggerFullSync(ctx);
28
28
  return true;
29
29
  }
30
30
  async pushOrderToQls(ctx, input) {
@@ -0,0 +1,8 @@
1
+ import { RequestContext } from '@vendure/core';
2
+ import { QlsOrderService } from '../services/qls-order.service';
3
+ import { QlsServicePoint, QlsServicePointSearchInput } from './generated/graphql';
4
+ export declare class QlsShopResolver {
5
+ private qlsOrderService;
6
+ constructor(qlsOrderService: QlsOrderService);
7
+ qlsServicePoints(ctx: RequestContext, input: QlsServicePointSearchInput): Promise<QlsServicePoint[]>;
8
+ }
@@ -0,0 +1,39 @@
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
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.QlsShopResolver = void 0;
16
+ const graphql_1 = require("@nestjs/graphql");
17
+ const core_1 = require("@vendure/core");
18
+ const qls_order_service_1 = require("../services/qls-order.service");
19
+ let QlsShopResolver = class QlsShopResolver {
20
+ constructor(qlsOrderService) {
21
+ this.qlsOrderService = qlsOrderService;
22
+ }
23
+ async qlsServicePoints(ctx, input) {
24
+ return await this.qlsOrderService.getServicePoints(ctx, input);
25
+ }
26
+ };
27
+ exports.QlsShopResolver = QlsShopResolver;
28
+ __decorate([
29
+ (0, graphql_1.Query)(),
30
+ __param(0, (0, core_1.Ctx)()),
31
+ __param(1, (0, graphql_1.Args)('input')),
32
+ __metadata("design:type", Function),
33
+ __metadata("design:paramtypes", [core_1.RequestContext, Object]),
34
+ __metadata("design:returntype", Promise)
35
+ ], QlsShopResolver.prototype, "qlsServicePoints", null);
36
+ exports.QlsShopResolver = QlsShopResolver = __decorate([
37
+ (0, graphql_1.Resolver)(),
38
+ __metadata("design:paramtypes", [qls_order_service_1.QlsOrderService])
39
+ ], QlsShopResolver);
@@ -31,7 +31,8 @@ let QlsWebhooksController = class QlsWebhooksController {
31
31
  */
32
32
  async events(channelToken, webhookSecret, request, body) {
33
33
  if (webhookSecret !== this.options.webhookSecret) {
34
- return core_1.Logger.warn(`Incoming webhook with invalid secret for channel '${channelToken}' to '${request.url}'`, constants_1.loggerCtx);
34
+ core_1.Logger.warn(`Incoming webhook with invalid secret for channel '${channelToken}' to '${request.url}'`, constants_1.loggerCtx);
35
+ throw new core_1.ForbiddenError();
35
36
  }
36
37
  try {
37
38
  const ctx = await this.getCtxForChannel(channelToken);
@@ -3,5 +3,11 @@ declare module '@vendure/core' {
3
3
  interface CustomProductVariantFields {
4
4
  qlsProductId?: string;
5
5
  }
6
+ interface CustomOrderFields {
7
+ qlsServicePointId?: string;
8
+ qlsServicePointDetails?: string;
9
+ syncedToQls?: boolean;
10
+ }
6
11
  }
7
12
  export declare const variantCustomFields: CustomFieldConfig[];
13
+ export declare const orderCustomFields: CustomFieldConfig[];
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.variantCustomFields = void 0;
3
+ exports.orderCustomFields = exports.variantCustomFields = void 0;
4
4
  const core_1 = require("@vendure/core");
5
5
  exports.variantCustomFields = [
6
6
  {
@@ -13,3 +13,53 @@ exports.variantCustomFields = [
13
13
  ui: { tab: 'QLS' },
14
14
  },
15
15
  ];
16
+ exports.orderCustomFields = [
17
+ {
18
+ name: 'qlsServicePointId',
19
+ type: 'string',
20
+ label: [{ value: 'QLS Service Point ID', languageCode: core_1.LanguageCode.en }],
21
+ nullable: true,
22
+ public: true,
23
+ readonly: false,
24
+ ui: { tab: 'QLS' },
25
+ },
26
+ {
27
+ name: 'syncedToQls',
28
+ type: 'boolean',
29
+ label: [
30
+ { value: 'Created in QLS', languageCode: core_1.LanguageCode.en },
31
+ { value: 'Aangemaakt in QLS', languageCode: core_1.LanguageCode.nl },
32
+ ],
33
+ description: [
34
+ {
35
+ value: 'Uncheck this to be able to push the order to QLS again',
36
+ languageCode: core_1.LanguageCode.en,
37
+ },
38
+ {
39
+ value: 'Vink dit uit om de order opnieuw naar QLS te sturen',
40
+ languageCode: core_1.LanguageCode.nl,
41
+ },
42
+ ],
43
+ nullable: true,
44
+ public: false,
45
+ readonly: false,
46
+ ui: { tab: 'QLS' },
47
+ },
48
+ {
49
+ name: 'qlsServicePointDetails',
50
+ type: 'string',
51
+ label: [
52
+ { value: 'QLS Service Point Details', languageCode: core_1.LanguageCode.en },
53
+ ],
54
+ description: [
55
+ {
56
+ value: 'Only used for display purposes.',
57
+ languageCode: core_1.LanguageCode.en,
58
+ },
59
+ ],
60
+ nullable: true,
61
+ public: true,
62
+ readonly: false,
63
+ ui: { tab: 'QLS' },
64
+ },
65
+ ];
@@ -79,7 +79,9 @@ export interface FulfillmentOrderInput {
79
79
  receiver_contact: FulfillmentOrderReceiverContactInput;
80
80
  custom_values?: CustomValue[];
81
81
  products: FulfillmentOrderLineInput[];
82
- delivery_options: string[];
82
+ delivery_options: {
83
+ tag: string;
84
+ }[];
83
85
  }
84
86
  export interface FulfillmentOrderReceiverContactInput {
85
87
  name: string;
@@ -1,6 +1,7 @@
1
1
  import { RequestContext } from '@vendure/core';
2
2
  import { QlsClientConfig, QlsPluginOptions } from '../types';
3
3
  import type { FulfillmentOrder, FulfillmentOrderInput, FulfillmentProduct, FulfillmentProductInput, QlsApiResponse } from './client-types';
4
+ import { QlsServicePoint } from '../api/generated/graphql';
4
5
  export declare function getQlsClient(ctx: RequestContext, pluginOptions: QlsPluginOptions): Promise<QlsClient | undefined>;
5
6
  /**
6
7
  * Wrapper around the QLS Rest API.
@@ -20,7 +21,8 @@ export declare class QlsClient {
20
21
  */
21
22
  getAllFulfillmentProducts(): Promise<FulfillmentProduct[]>;
22
23
  createFulfillmentProduct(data: FulfillmentProductInput): Promise<FulfillmentProduct>;
23
- updateFulfillmentProduct(fulfillmentProductId: string, data: FulfillmentProductInput): Promise<FulfillmentProduct>;
24
+ updateFulfillmentProduct(fulfillmentProductId: string, data: FulfillmentProductInput): Promise<void>;
25
+ deleteFulfillmentProduct(fulfillmentProductId: string): Promise<void>;
24
26
  createFulfillmentOrder(data: Omit<FulfillmentOrderInput, 'brand_id'>): Promise<FulfillmentOrder>;
25
27
  /**
26
28
  * Add an extra barcode to a fulfillment product in QLS
@@ -30,5 +32,6 @@ export declare class QlsClient {
30
32
  * Add an extra barcode to a fulfillment product in QLS
31
33
  */
32
34
  removeBarcode(productId: string, barcodeId: number): Promise<void>;
33
- rawRequest<T>(method: 'POST' | 'GET' | 'PUT' | 'DELETE', action: string, data?: unknown): Promise<QlsApiResponse<T>>;
35
+ getServicePoints(countryCode: string, postalCode: string): Promise<QlsServicePoint[]>;
36
+ rawRequest<T>(method: 'POST' | 'GET' | 'PUT' | 'DELETE', action: string, data?: unknown): Promise<QlsApiResponse<T | undefined>>;
34
37
  }
@@ -26,7 +26,7 @@ class QlsClient {
26
26
  */
27
27
  async getFulfillmentProductBySku(sku) {
28
28
  const result = await this.rawRequest('GET', `fulfillment/products?filter%5Bsku%5D=${encodeURIComponent(sku)}`);
29
- if (result.data.length === 0) {
29
+ if (!result.data || result.data.length === 0) {
30
30
  return undefined;
31
31
  }
32
32
  if (result.data.length > 1) {
@@ -57,17 +57,25 @@ class QlsClient {
57
57
  }
58
58
  async createFulfillmentProduct(data) {
59
59
  const response = await this.rawRequest('POST', 'fulfillment/products', data);
60
+ if (!response.data) {
61
+ throw new Error('Failed to create fulfillment product. Got empty response.');
62
+ }
60
63
  return response.data;
61
64
  }
62
65
  async updateFulfillmentProduct(fulfillmentProductId, data) {
63
- const response = await this.rawRequest('PUT', `fulfillment/products/${fulfillmentProductId}`, data);
64
- return response.data;
66
+ await this.rawRequest('PUT', `fulfillment/products/${fulfillmentProductId}`, data);
67
+ }
68
+ async deleteFulfillmentProduct(fulfillmentProductId) {
69
+ await this.rawRequest('DELETE', `fulfillment/products/${fulfillmentProductId}`);
65
70
  }
66
71
  async createFulfillmentOrder(data) {
67
72
  const response = await this.rawRequest('POST', 'fulfillment/orders', {
68
73
  ...data,
69
74
  brand_id: this.config.brandId,
70
75
  });
76
+ if (!response.data) {
77
+ throw new Error('Failed to create fulfillment order. Got empty response.');
78
+ }
71
79
  return response.data;
72
80
  }
73
81
  /**
@@ -84,6 +92,11 @@ class QlsClient {
84
92
  async removeBarcode(productId, barcodeId) {
85
93
  await this.rawRequest('DELETE', `fulfillment/products/${productId}/barcodes/${barcodeId}`);
86
94
  }
95
+ async getServicePoints(countryCode, postalCode) {
96
+ countryCode = countryCode.toUpperCase();
97
+ const result = await this.rawRequest('GET', `service-points/${countryCode}/${postalCode}`);
98
+ return result.data ?? [];
99
+ }
87
100
  async rawRequest(method, action, data) {
88
101
  // Set headers
89
102
  const headers = {
@@ -98,10 +111,19 @@ class QlsClient {
98
111
  headers,
99
112
  body,
100
113
  });
114
+ if (response.status === 204) {
115
+ // 204 No Content
116
+ return {
117
+ data: undefined,
118
+ meta: {
119
+ code: response.status,
120
+ },
121
+ };
122
+ }
101
123
  if (!response.ok) {
102
124
  const errorText = await response.text();
103
125
  // Log error including the request body
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);
126
+ core_1.Logger.error(`[QLS] '${method}' request to '${url}' failed: ${response.status} ${response.statusText} - ${errorText}`, constants_1.loggerCtx, data ? JSON.stringify(data, null, 2) : undefined);
105
127
  throw new Error(errorText);
106
128
  }
107
129
  const contentType = response.headers.get('content-type') ?? '';
@@ -15,6 +15,7 @@ const core_1 = require("@vendure/core");
15
15
  const path_1 = __importDefault(require("path"));
16
16
  const api_extensions_1 = require("./api/api-extensions");
17
17
  const qls_admin_resolver_1 = require("./api/qls-admin.resolver");
18
+ const qls_shop_resolver_1 = require("./api/qls-shop.resolver");
18
19
  const qls_webhooks_controller_1 = require("./api/qls-webhooks-controller");
19
20
  const permissions_1 = require("./config/permissions");
20
21
  const constants_1 = require("./constants");
@@ -23,7 +24,11 @@ const qls_order_service_1 = require("./services/qls-order.service");
23
24
  const qls_product_service_1 = require("./services/qls-product.service");
24
25
  let QlsPlugin = QlsPlugin_1 = class QlsPlugin {
25
26
  static init(options) {
26
- this.options = options;
27
+ this.options = {
28
+ synchronizeStockLevels: true,
29
+ autoPushOrders: true,
30
+ ...options,
31
+ };
27
32
  return QlsPlugin_1;
28
33
  }
29
34
  };
@@ -49,6 +54,7 @@ exports.QlsPlugin = QlsPlugin = QlsPlugin_1 = __decorate([
49
54
  config.authOptions.customPermissions.push(permissions_1.qlsFullSyncPermission);
50
55
  config.authOptions.customPermissions.push(permissions_1.qlsPushOrderPermission);
51
56
  config.customFields.ProductVariant.push(...custom_fields_1.variantCustomFields);
57
+ config.customFields.Order.push(...custom_fields_1.orderCustomFields);
52
58
  return config;
53
59
  },
54
60
  compatibility: '>=3.2.0',
@@ -56,5 +62,9 @@ exports.QlsPlugin = QlsPlugin = QlsPlugin_1 = __decorate([
56
62
  schema: api_extensions_1.adminApiExtensions,
57
63
  resolvers: [qls_admin_resolver_1.QlsAdminResolver],
58
64
  },
65
+ shopApiExtensions: {
66
+ schema: api_extensions_1.shopApiExtensions,
67
+ resolvers: [qls_shop_resolver_1.QlsShopResolver],
68
+ },
59
69
  })
60
70
  ], QlsPlugin);
@@ -0,0 +1,35 @@
1
+ import { Order, RequestContext, VendureEvent } from '@vendure/core';
2
+ export declare enum QLSOrderError {
3
+ INCORRECT_POSTAL_CODE = "INCORRECT_POSTAL_CODE",
4
+ INCORRECT_HOUSE_NUMBER = "INCORRECT_HOUSE_NUMBER",
5
+ UNKNOWN_ERROR = "UNKNOWN_ERROR"
6
+ }
7
+ /**
8
+ * This event is emitted when an order fails to be pushed to QLS.
9
+ */
10
+ export declare class QlsOrderFailedEvent extends VendureEvent {
11
+ ctx: RequestContext;
12
+ order: Order;
13
+ /**
14
+ * The date and time the order failed to be pushed to QLS.
15
+ */
16
+ failedAt: Date;
17
+ /**
18
+ * The full error response JSON from QLS.
19
+ */
20
+ fullError: unknown;
21
+ /**
22
+ * The error code that caused the order to fail to be pushed to QLS.
23
+ */
24
+ errorCode: QLSOrderError;
25
+ constructor(ctx: RequestContext, order: Order,
26
+ /**
27
+ * The date and time the order failed to be pushed to QLS.
28
+ */
29
+ failedAt: Date,
30
+ /**
31
+ * The full error response JSON from QLS.
32
+ */
33
+ fullError: unknown);
34
+ }
35
+ export declare function getQLSErrorCode(errorResponse: unknown): QLSOrderError;
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.QlsOrderFailedEvent = exports.QLSOrderError = void 0;
4
+ exports.getQLSErrorCode = getQLSErrorCode;
5
+ const core_1 = require("@vendure/core");
6
+ var QLSOrderError;
7
+ (function (QLSOrderError) {
8
+ QLSOrderError["INCORRECT_POSTAL_CODE"] = "INCORRECT_POSTAL_CODE";
9
+ QLSOrderError["INCORRECT_HOUSE_NUMBER"] = "INCORRECT_HOUSE_NUMBER";
10
+ QLSOrderError["UNKNOWN_ERROR"] = "UNKNOWN_ERROR";
11
+ })(QLSOrderError || (exports.QLSOrderError = QLSOrderError = {}));
12
+ /**
13
+ * This event is emitted when an order fails to be pushed to QLS.
14
+ */
15
+ class QlsOrderFailedEvent extends core_1.VendureEvent {
16
+ constructor(ctx, order,
17
+ /**
18
+ * The date and time the order failed to be pushed to QLS.
19
+ */
20
+ failedAt,
21
+ /**
22
+ * The full error response JSON from QLS.
23
+ */
24
+ fullError) {
25
+ super();
26
+ this.ctx = ctx;
27
+ this.order = order;
28
+ this.failedAt = failedAt;
29
+ this.fullError = fullError;
30
+ this.errorCode = getQLSErrorCode(fullError);
31
+ }
32
+ }
33
+ exports.QlsOrderFailedEvent = QlsOrderFailedEvent;
34
+ function getQLSErrorCode(errorResponse) {
35
+ const errorString = JSON.stringify(errorResponse);
36
+ if (errorString.includes('containsNumber')) {
37
+ return QLSOrderError.INCORRECT_HOUSE_NUMBER;
38
+ }
39
+ if (errorString.includes('validPostalCode') ||
40
+ errorString.includes('needPostalCodeVerification')) {
41
+ return QLSOrderError.INCORRECT_POSTAL_CODE;
42
+ }
43
+ return QLSOrderError.UNKNOWN_ERROR;
44
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const vitest_1 = require("vitest");
4
+ const qls_order_failed_event_1 = require("./qls-order-failed-event");
5
+ (0, vitest_1.describe)('QlsOrderFailedEvent', () => {
6
+ const mockCtx = {};
7
+ const mockOrder = { id: 1, code: 'ORDER-001' };
8
+ const mockFailedAt = new Date('2026-01-03T04:34:52.473Z');
9
+ (0, vitest_1.it)('sets errorCode to INCORRECT_HOUSE_NUMBER for containsNumber error', () => {
10
+ const fullError = {
11
+ errors: {
12
+ receiver_contact: {
13
+ housenumber: { containsNumber: 'Huisnummer bevat geen nummer' },
14
+ },
15
+ },
16
+ pagination: null,
17
+ };
18
+ const event = new qls_order_failed_event_1.QlsOrderFailedEvent(mockCtx, mockOrder, mockFailedAt, fullError);
19
+ (0, vitest_1.expect)(event.errorCode).toBe(qls_order_failed_event_1.QLSOrderError.INCORRECT_HOUSE_NUMBER);
20
+ (0, vitest_1.expect)(event.ctx).toBe(mockCtx);
21
+ (0, vitest_1.expect)(event.order).toBe(mockOrder);
22
+ (0, vitest_1.expect)(event.failedAt).toBe(mockFailedAt);
23
+ (0, vitest_1.expect)(event.fullError).toBe(fullError);
24
+ });
25
+ (0, vitest_1.it)('sets errorCode to INCORRECT_POSTAL_CODE for validPostalCode error', () => {
26
+ const fullError = {
27
+ errors: {
28
+ receiver_contact: {
29
+ postalcode: { validPostalCode: 'Ongeldige indeling (NNNN)' },
30
+ },
31
+ },
32
+ pagination: null,
33
+ };
34
+ const event = new qls_order_failed_event_1.QlsOrderFailedEvent(mockCtx, mockOrder, mockFailedAt, fullError);
35
+ (0, vitest_1.expect)(event.errorCode).toBe(qls_order_failed_event_1.QLSOrderError.INCORRECT_POSTAL_CODE);
36
+ });
37
+ (0, vitest_1.it)('sets errorCode to INCORRECT_POSTAL_CODE for needPostalCodeVerification error', () => {
38
+ const fullError = {
39
+ errors: {
40
+ receiver_contact: {
41
+ postalcode: { needPostalCodeVerification: 'Ongeldige postcode' },
42
+ },
43
+ },
44
+ pagination: null,
45
+ };
46
+ const event = new qls_order_failed_event_1.QlsOrderFailedEvent(mockCtx, mockOrder, mockFailedAt, fullError);
47
+ (0, vitest_1.expect)(event.errorCode).toBe(qls_order_failed_event_1.QLSOrderError.INCORRECT_POSTAL_CODE);
48
+ });
49
+ (0, vitest_1.it)('sets errorCode to INCORRECT_POSTAL_CODE for combined postal code errors', () => {
50
+ const fullError = {
51
+ errors: {
52
+ receiver_contact: {
53
+ postalcode: {
54
+ needPostalCodeVerification: 'Ongeldige postcode',
55
+ validPostalCode: 'Ongeldige indeling (NNNN LL)',
56
+ },
57
+ },
58
+ },
59
+ pagination: null,
60
+ };
61
+ const event = new qls_order_failed_event_1.QlsOrderFailedEvent(mockCtx, mockOrder, mockFailedAt, fullError);
62
+ (0, vitest_1.expect)(event.errorCode).toBe(qls_order_failed_event_1.QLSOrderError.INCORRECT_POSTAL_CODE);
63
+ });
64
+ (0, vitest_1.it)('sets errorCode to UNKNOWN_ERROR for 502 Bad Gateway HTML response', () => {
65
+ const fullError = {
66
+ html: '<html> <head><title>502 Bad Gateway</title></head> <body> <center><h1>502 Bad Gateway</h1></center> <hr><center>nginx</center> </body> </html>',
67
+ };
68
+ const event = new qls_order_failed_event_1.QlsOrderFailedEvent(mockCtx, mockOrder, mockFailedAt, fullError);
69
+ (0, vitest_1.expect)(event.errorCode).toBe(qls_order_failed_event_1.QLSOrderError.UNKNOWN_ERROR);
70
+ });
71
+ (0, vitest_1.it)('sets errorCode to UNKNOWN_ERROR for unknown error format', () => {
72
+ const fullError = {
73
+ meta: { code: 500 },
74
+ errors: { unknown: 'Some other error' },
75
+ };
76
+ const event = new qls_order_failed_event_1.QlsOrderFailedEvent(mockCtx, mockOrder, mockFailedAt, fullError);
77
+ (0, vitest_1.expect)(event.errorCode).toBe(qls_order_failed_event_1.QLSOrderError.UNKNOWN_ERROR);
78
+ });
79
+ });
@@ -1,17 +1,17 @@
1
1
  import { OnApplicationBootstrap, OnModuleInit } from '@nestjs/common';
2
2
  import { ModuleRef } from '@nestjs/core';
3
- import { EventBus, ID, Job, JobQueueService, OrderService, RequestContext, TransactionalConnection } from '@vendure/core';
3
+ import { EventBus, ID, Job, JobQueueService, OrderService, RequestContext } from '@vendure/core';
4
+ import { QlsServicePoint, QlsServicePointSearchInput } from '../api/generated/graphql';
4
5
  import { IncomingOrderWebhook } from '../lib/client-types';
5
6
  import { QlsOrderJobData, QlsPluginOptions } from '../types';
6
7
  export declare class QlsOrderService implements OnModuleInit, OnApplicationBootstrap {
7
- private connection;
8
8
  private options;
9
9
  private jobQueueService;
10
10
  private eventBus;
11
11
  private orderService;
12
12
  private moduleRef;
13
13
  private orderJobQueue;
14
- constructor(connection: TransactionalConnection, options: QlsPluginOptions, jobQueueService: JobQueueService, eventBus: EventBus, orderService: OrderService, moduleRef: ModuleRef);
14
+ constructor(options: QlsPluginOptions, jobQueueService: JobQueueService, eventBus: EventBus, orderService: OrderService, moduleRef: ModuleRef);
15
15
  onApplicationBootstrap(): void;
16
16
  onModuleInit(): Promise<void>;
17
17
  /**
@@ -19,11 +19,16 @@ export declare class QlsOrderService implements OnModuleInit, OnApplicationBoots
19
19
  * Returns the result of the job, which will be stored in the job record.
20
20
  */
21
21
  handleOrderJob(job: Job<QlsOrderJobData>): Promise<unknown>;
22
+ /**
23
+ * Push an order to QLS by id.
24
+ * Returns a human-readable message describing the result of the operation (Used as job result).
25
+ */
22
26
  pushOrderToQls(ctx: RequestContext, orderId: ID): Promise<string>;
23
27
  /**
24
28
  * Update the status of an order in QLS based on the given order code and status
25
29
  */
26
30
  handleOrderStatusUpdate(ctx: RequestContext, body: IncomingOrderWebhook): Promise<void>;
27
31
  triggerPushOrder(ctx: RequestContext, orderId: ID, orderCode?: string): Promise<Job<QlsOrderJobData> | undefined>;
32
+ getServicePoints(ctx: RequestContext, input: QlsServicePointSearchInput): Promise<QlsServicePoint[]>;
28
33
  private getVendureOrderState;
29
34
  }
@@ -23,9 +23,9 @@ const catch_unknown_1 = require("catch-unknown");
23
23
  const util_1 = __importDefault(require("util"));
24
24
  const constants_1 = require("../constants");
25
25
  const qls_client_1 = require("../lib/qls-client");
26
+ const qls_order_failed_event_1 = require("./qls-order-failed-event");
26
27
  let QlsOrderService = class QlsOrderService {
27
- constructor(connection, options, jobQueueService, eventBus, orderService, moduleRef) {
28
- this.connection = connection;
28
+ constructor(options, jobQueueService, eventBus, orderService, moduleRef) {
29
29
  this.options = options;
30
30
  this.jobQueueService = jobQueueService;
31
31
  this.eventBus = eventBus;
@@ -35,6 +35,10 @@ let QlsOrderService = class QlsOrderService {
35
35
  onApplicationBootstrap() {
36
36
  // Listen for OrderPlacedEvent and add a job to the queue
37
37
  this.eventBus.ofType(core_2.OrderPlacedEvent).subscribe((event) => {
38
+ if (!this.options.autoPushOrders) {
39
+ core_2.Logger.info(`Auto push orders disabled, not triggering push order job for order ${event.order.code}`, constants_1.loggerCtx);
40
+ return;
41
+ }
38
42
  this.triggerPushOrder(event.ctx, event.order.id, event.order.code).catch((e) => {
39
43
  const error = (0, catch_unknown_1.asError)(e);
40
44
  core_2.Logger.error(`Failed to trigger push order job for order ${event.order.code}: ${error.message}`, constants_1.loggerCtx, error.stack);
@@ -72,6 +76,10 @@ let QlsOrderService = class QlsOrderService {
72
76
  throw error;
73
77
  }
74
78
  }
79
+ /**
80
+ * Push an order to QLS by id.
81
+ * Returns a human-readable message describing the result of the operation (Used as job result).
82
+ */
75
83
  async pushOrderToQls(ctx, orderId) {
76
84
  const client = await (0, qls_client_1.getQlsClient)(ctx, this.options);
77
85
  if (!client) {
@@ -82,18 +90,33 @@ let QlsOrderService = class QlsOrderService {
82
90
  if (!order) {
83
91
  throw new Error(`No order with id ${orderId} not found`);
84
92
  }
93
+ if (order.customFields.syncedToQls) {
94
+ throw new core_2.UserInputError(`Order '${order.code}' has already been synced to QLS`);
95
+ }
85
96
  try {
86
- // Check if all products are available in QLS
87
- const qlsProducts = order.lines.map((line) => {
97
+ // Map variants to QLS products
98
+ const qlsProducts = [];
99
+ await Promise.all(order.lines.map(async (line) => {
100
+ // Check if product variant should be excluded from sync
101
+ if (await this.options.excludeVariantFromSync?.(ctx, new core_2.Injector(this.moduleRef), line.productVariant)) {
102
+ core_2.Logger.info(`Product variant '${line.productVariant.sku}' not sent to QLS in order '${order.code}' because it is excluded from sync.`, constants_1.loggerCtx);
103
+ return;
104
+ }
105
+ // Check if product is available in QLS
88
106
  if (!line.productVariant.customFields.qlsProductId) {
89
107
  throw new Error(`Product variant '${line.productVariant.sku}' does not have a QLS product ID set. Unable to push order '${order.code}' to QLS.`);
90
108
  }
91
- return {
109
+ qlsProducts.push({
92
110
  amount_ordered: line.quantity,
93
111
  product_id: line.productVariant.customFields.qlsProductId,
94
112
  name: line.productVariant.name,
95
- };
96
- });
113
+ });
114
+ }));
115
+ if (qlsProducts.length === 0) {
116
+ const message = `No products to push to QLS for order '${order.code}'. Ignoring order.`;
117
+ core_2.Logger.info(message, constants_1.loggerCtx);
118
+ return message;
119
+ }
97
120
  const additionalOrderFields = await this.options.getAdditionalOrderFields?.(ctx, new core_2.Injector(this.moduleRef), order);
98
121
  const customerName = [order.customer?.firstName, order.customer?.lastName]
99
122
  .filter(Boolean)
@@ -112,15 +135,17 @@ let QlsOrderService = class QlsOrderService {
112
135
  !order.shippingAddress.countryCode) {
113
136
  throw new Error(`Shipping address for order '${order.code}' is missing one of required fields: streetLine1, postalCode, city, streetLine2, countryCode. Can not push order to QLS.`);
114
137
  }
138
+ const processable = (await this.options.processOrderFrom?.(ctx, order)) ?? new Date();
139
+ const receiverContact = this.options.getReceiverContact?.(ctx, order);
115
140
  const qlsOrder = {
116
141
  customer_reference: order.code,
117
- processable: new Date().toISOString(), // Processable starting now
118
- servicepoint_code: additionalOrderFields?.servicepoint_code,
142
+ processable: processable.toISOString(),
143
+ servicepoint_code: order.customFields?.qlsServicePointId,
119
144
  delivery_options: additionalOrderFields?.delivery_options ?? [],
120
145
  total_price: order.totalWithTax,
121
- receiver_contact: {
146
+ receiver_contact: receiverContact ?? {
122
147
  name: order.shippingAddress.fullName || customerName,
123
- companyname: order.shippingAddress.company,
148
+ companyname: order.shippingAddress.company ?? '',
124
149
  street: order.shippingAddress.streetLine1,
125
150
  housenumber: order.shippingAddress.streetLine2,
126
151
  postalcode: order.shippingAddress.postalCode,
@@ -133,11 +158,23 @@ let QlsOrderService = class QlsOrderService {
133
158
  ...(additionalOrderFields ?? {}),
134
159
  };
135
160
  const result = await client.createFulfillmentOrder(qlsOrder);
161
+ core_2.Logger.info(`Successfully created order '${order.code}' in QLS with id '${result.id}'`, constants_1.loggerCtx);
136
162
  await this.orderService.addNoteToOrder(ctx, {
137
163
  id: orderId,
138
164
  isPublic: false,
139
165
  note: `Created order '${result.id}' in QLS`,
140
166
  });
167
+ // Delay 10s to prevent race conditions: OrderPlacedEvent is emitted before transition to PaymentSettled is complete
168
+ setTimeout(() => {
169
+ this.orderService
170
+ .updateCustomFields(ctx, orderId, {
171
+ syncedToQls: true,
172
+ })
173
+ .catch((e) => {
174
+ const error = (0, catch_unknown_1.asError)(e);
175
+ core_2.Logger.error(`Error updating custom field 'syncedToQls: true' for order '${order.code}': ${error.message}`, constants_1.loggerCtx, error.stack);
176
+ });
177
+ }, 10000);
141
178
  return `Order '${order.code}' created in QLS with id '${result.id}'`;
142
179
  }
143
180
  catch (e) {
@@ -147,6 +184,7 @@ let QlsOrderService = class QlsOrderService {
147
184
  isPublic: false,
148
185
  note: `Failed to create order '${order.code}' in QLS: ${error.message}`,
149
186
  });
187
+ await this.eventBus.publish(new qls_order_failed_event_1.QlsOrderFailedEvent(ctx, order, new Date(), error.message));
150
188
  throw error;
151
189
  }
152
190
  }
@@ -154,6 +192,7 @@ let QlsOrderService = class QlsOrderService {
154
192
  * Update the status of an order in QLS based on the given order code and status
155
193
  */
156
194
  async handleOrderStatusUpdate(ctx, body) {
195
+ core_2.Logger.info(`Handling QLS order status update for order '${body.customer_reference}' with status '${body.status} and amount_delivered '${body.amount_delivered}' and amount_total '${body.amount_total}'`, constants_1.loggerCtx);
157
196
  const orderCode = body.customer_reference;
158
197
  const order = await this.orderService.findOneByCode(ctx, orderCode, []);
159
198
  if (!order) {
@@ -182,7 +221,14 @@ let QlsOrderService = class QlsOrderService {
182
221
  action: 'push-order',
183
222
  ctx: ctx.serialize(),
184
223
  orderId,
185
- }, { retries: 5 });
224
+ }, { retries: 3 });
225
+ }
226
+ async getServicePoints(ctx, input) {
227
+ const client = await (0, qls_client_1.getQlsClient)(ctx, this.options);
228
+ if (!client) {
229
+ throw new core_2.UserInputError(`QLS not enabled for channel ${ctx.channel.token}`);
230
+ }
231
+ return await client.getServicePoints(input.countryCode, input.postalCode);
186
232
  }
187
233
  getVendureOrderState(body) {
188
234
  if (body.cancelled) {
@@ -202,8 +248,8 @@ let QlsOrderService = class QlsOrderService {
202
248
  exports.QlsOrderService = QlsOrderService;
203
249
  exports.QlsOrderService = QlsOrderService = __decorate([
204
250
  (0, common_1.Injectable)(),
205
- __param(1, (0, common_1.Inject)(constants_1.PLUGIN_INIT_OPTIONS)),
206
- __metadata("design:paramtypes", [core_2.TransactionalConnection, Object, core_2.JobQueueService,
251
+ __param(0, (0, common_1.Inject)(constants_1.PLUGIN_INIT_OPTIONS)),
252
+ __metadata("design:paramtypes", [Object, core_2.JobQueueService,
207
253
  core_2.EventBus,
208
254
  core_2.OrderService,
209
255
  core_1.ModuleRef])
@@ -3,11 +3,12 @@ import { EventBus, ID, Job, JobQueueService, ListQueryBuilder, ProductPriceAppli
3
3
  import { FulfillmentProduct } from '../lib/client-types';
4
4
  import { QlsClient } from '../lib/qls-client';
5
5
  import { QlsPluginOptions, QlsProductJobData } from '../types';
6
- type SyncProductsJobResult = {
7
- updatedInQls: number;
8
- createdInQls: number;
9
- updatedStock: number;
10
- failed: number;
6
+ import { ModuleRef } from '@nestjs/core';
7
+ type SyncProductsResult = {
8
+ updatedInQls: Partial<ProductVariant>[];
9
+ createdInQls: Partial<ProductVariant>[];
10
+ updatedStock: Partial<ProductVariant>[];
11
+ failed: Partial<ProductVariant>[];
11
12
  };
12
13
  export declare class QlsProductService implements OnModuleInit, OnApplicationBootstrap {
13
14
  private connection;
@@ -19,8 +20,9 @@ export declare class QlsProductService implements OnModuleInit, OnApplicationBoo
19
20
  private readonly eventBus;
20
21
  private readonly listQueryBuilder;
21
22
  private readonly productPriceApplicator;
23
+ private readonly moduleRef;
22
24
  private productJobQueue;
23
- constructor(connection: TransactionalConnection, options: QlsPluginOptions, jobQueueService: JobQueueService, stockLevelService: StockLevelService, variantService: ProductVariantService, stockLocationService: StockLocationService, eventBus: EventBus, listQueryBuilder: ListQueryBuilder, productPriceApplicator: ProductPriceApplicator);
25
+ constructor(connection: TransactionalConnection, options: QlsPluginOptions, jobQueueService: JobQueueService, stockLevelService: StockLevelService, variantService: ProductVariantService, stockLocationService: StockLocationService, eventBus: EventBus, listQueryBuilder: ListQueryBuilder, productPriceApplicator: ProductPriceApplicator, moduleRef: ModuleRef);
24
26
  onApplicationBootstrap(): void;
25
27
  onModuleInit(): Promise<void>;
26
28
  /**
@@ -35,11 +37,16 @@ export declare class QlsProductService implements OnModuleInit, OnApplicationBoo
35
37
  * 3. Creates products in QLS if needed
36
38
  * 4. Updates products in QLS if needed
37
39
  */
38
- runFullSync(ctx: RequestContext): Promise<SyncProductsJobResult>;
40
+ runFullSync(ctx: RequestContext): Promise<SyncProductsResult>;
41
+ /**
42
+ * Utility function to remove all products from QLS.
43
+ * You might need to cancel active orders in QLS first, because products cannot be deleted if they are in an active order.
44
+ */
45
+ removeAllProductsFromQls(ctx: RequestContext): Promise<void>;
39
46
  /**
40
47
  * Creates or updates the fulfillment products in QLS for the given product variants.
41
48
  */
42
- syncVariants(ctx: RequestContext, productVariantIds: ID[]): Promise<SyncProductsJobResult>;
49
+ syncVariants(ctx: RequestContext, productVariantIds: ID[]): Promise<SyncProductsResult>;
43
50
  /**
44
51
  * Trigger a full product sync job
45
52
  */
@@ -24,8 +24,11 @@ const util_1 = __importDefault(require("util"));
24
24
  const constants_1 = require("../constants");
25
25
  const qls_client_1 = require("../lib/qls-client");
26
26
  const util_2 = require("./util");
27
+ const core_2 = require("@nestjs/core");
28
+ // Wait for 700ms to avoid rate limit of 500/5 minutes
29
+ const waitToPreventRateLimit = () => new Promise((resolve) => setTimeout(resolve, 700));
27
30
  let QlsProductService = class QlsProductService {
28
- constructor(connection, options, jobQueueService, stockLevelService, variantService, stockLocationService, eventBus, listQueryBuilder, productPriceApplicator) {
31
+ constructor(connection, options, jobQueueService, stockLevelService, variantService, stockLocationService, eventBus, listQueryBuilder, productPriceApplicator, moduleRef) {
29
32
  this.connection = connection;
30
33
  this.options = options;
31
34
  this.jobQueueService = jobQueueService;
@@ -35,6 +38,7 @@ let QlsProductService = class QlsProductService {
35
38
  this.eventBus = eventBus;
36
39
  this.listQueryBuilder = listQueryBuilder;
37
40
  this.productPriceApplicator = productPriceApplicator;
41
+ this.moduleRef = moduleRef;
38
42
  }
39
43
  onApplicationBootstrap() {
40
44
  // Listen for ProductVariantEvent and add a job to the queue
@@ -64,7 +68,13 @@ let QlsProductService = class QlsProductService {
64
68
  try {
65
69
  const ctx = core_1.RequestContext.deserialize(job.data.ctx);
66
70
  if (job.data.action === 'full-sync-products') {
67
- return await this.runFullSync(ctx);
71
+ const result = await this.runFullSync(ctx);
72
+ return {
73
+ updatedInQls: result.updatedInQls.length,
74
+ createdInQls: result.createdInQls.length,
75
+ updatedStock: result.updatedStock.length,
76
+ failed: result.failed.length,
77
+ };
68
78
  }
69
79
  else if (job.data.action === 'sync-products') {
70
80
  return await this.syncVariants(ctx, job.data.productVariantIds);
@@ -99,45 +109,49 @@ let QlsProductService = class QlsProductService {
99
109
  const allVariants = await this.getAllVariants(ctx);
100
110
  core_1.Logger.info(`Running full sync for ${allQlsProducts.length} QLS products and ${allVariants.length} Vendure variants`, constants_1.loggerCtx);
101
111
  // Update stock in Vendure based on QLS products
102
- let updateStockCount = 0;
112
+ const updatedStock = [];
103
113
  for (const variant of allVariants) {
104
114
  const qlsProduct = allQlsProducts.find((p) => p.sku == variant.sku);
105
115
  if (qlsProduct) {
106
116
  await this.updateStock(ctx, variant.id, qlsProduct.amount_available);
107
- updateStockCount += 1;
117
+ updatedStock.push(variant);
108
118
  }
109
119
  }
110
- core_1.Logger.info(`Updated stock for ${updateStockCount} variants based on QLS stock levels`, constants_1.loggerCtx);
120
+ core_1.Logger.info(`Updated stock for ${updatedStock.length} variants based on QLS stock levels`, constants_1.loggerCtx);
111
121
  // Create or update products in QLS
112
- let createdQlsProductsCount = 0;
113
- let updatedQlsProductsCount = 0;
114
- let failedCount = 0;
122
+ const createdQlsProducts = [];
123
+ const updatedQlsProducts = [];
124
+ const failed = [];
115
125
  for (const variant of allVariants) {
116
126
  try {
117
127
  const existingQlsProduct = allQlsProducts.find((p) => p.sku == variant.sku);
118
128
  const result = await this.createOrUpdateProductInQls(ctx, client, variant, existingQlsProduct ?? null);
119
129
  if (result === 'created') {
120
- createdQlsProductsCount += 1;
130
+ createdQlsProducts.push(variant);
121
131
  }
122
132
  else if (result === 'updated') {
123
- updatedQlsProductsCount += 1;
133
+ updatedQlsProducts.push(variant);
134
+ }
135
+ if (result === 'created' || result === 'updated') {
136
+ // Wait only if we created or updated a product, otherwise no calls have been made yet.
137
+ await waitToPreventRateLimit();
124
138
  }
125
139
  }
126
140
  catch (e) {
127
141
  const error = (0, catch_unknown_1.asError)(e);
128
142
  core_1.Logger.error(`Error creating or updating variant '${variant.sku}' in QLS: ${error.message}`, constants_1.loggerCtx, error.stack);
129
- failedCount += 1;
143
+ failed.push(variant);
144
+ await waitToPreventRateLimit();
130
145
  }
131
- await new Promise((resolve) => setTimeout(resolve, 700)); // Avoid rate limit of 500/5 minutes (700ms delay = 85/minute)
132
146
  }
133
- core_1.Logger.info(`Created ${createdQlsProductsCount} products in QLS`, constants_1.loggerCtx);
134
- core_1.Logger.info(`Updated ${updatedQlsProductsCount} products in QLS`, constants_1.loggerCtx);
135
- core_1.Logger.info(`Finished full sync with ${failedCount} failures`, constants_1.loggerCtx);
147
+ core_1.Logger.info(`Created ${createdQlsProducts.length} products in QLS`, constants_1.loggerCtx);
148
+ core_1.Logger.info(`Updated ${updatedQlsProducts.length} products in QLS`, constants_1.loggerCtx);
149
+ core_1.Logger.info(`Finished full sync with ${failed.length} failures`, constants_1.loggerCtx);
136
150
  return {
137
- updatedInQls: updatedQlsProductsCount,
138
- createdInQls: createdQlsProductsCount,
139
- updatedStock: updateStockCount,
140
- failed: failedCount,
151
+ updatedInQls: updatedQlsProducts,
152
+ createdInQls: createdQlsProducts,
153
+ updatedStock,
154
+ failed,
141
155
  };
142
156
  }
143
157
  catch (e) {
@@ -146,6 +160,24 @@ let QlsProductService = class QlsProductService {
146
160
  throw error;
147
161
  }
148
162
  }
163
+ /**
164
+ * Utility function to remove all products from QLS.
165
+ * You might need to cancel active orders in QLS first, because products cannot be deleted if they are in an active order.
166
+ */
167
+ async removeAllProductsFromQls(ctx) {
168
+ const client = await (0, qls_client_1.getQlsClient)(ctx, this.options);
169
+ if (!client) {
170
+ throw new Error(`QLS not enabled for channel ${ctx.channel.token}`);
171
+ }
172
+ core_1.Logger.warn(`Removing all products from QLS for channel ${ctx.channel.token}...`, constants_1.loggerCtx);
173
+ const allProducts = await client.getAllFulfillmentProducts();
174
+ for (const product of allProducts) {
175
+ await client.deleteFulfillmentProduct(product.id);
176
+ core_1.Logger.info(`Removed product '${product.sku}' (${product.id}) from QLS`, constants_1.loggerCtx);
177
+ await waitToPreventRateLimit();
178
+ }
179
+ core_1.Logger.warn(`Removed ${allProducts.length} products from QLS for channel ${ctx.channel.token}`, constants_1.loggerCtx);
180
+ }
149
181
  /**
150
182
  * Creates or updates the fulfillment products in QLS for the given product variants.
151
183
  */
@@ -154,15 +186,15 @@ let QlsProductService = class QlsProductService {
154
186
  if (!client) {
155
187
  core_1.Logger.debug(`QLS not enabled for channel ${ctx.channel.token}. Not handling product update/create.`, constants_1.loggerCtx);
156
188
  return {
157
- updatedInQls: 0,
158
- createdInQls: 0,
159
- updatedStock: 0,
160
- failed: 0,
189
+ updatedInQls: [],
190
+ createdInQls: [],
191
+ updatedStock: [],
192
+ failed: [],
161
193
  };
162
194
  }
163
- let updatedInQls = 0;
164
- let createdInQls = 0;
165
- let failedCount = 0;
195
+ const updatedInQls = [];
196
+ const createdInQls = [];
197
+ const failed = [];
166
198
  for (const variantId of productVariantIds) {
167
199
  try {
168
200
  const variant = await this.variantService.findOne(ctx, variantId, [
@@ -178,23 +210,23 @@ let QlsProductService = class QlsProductService {
178
210
  const existingQlsProduct = await client.getFulfillmentProductBySku(variant.sku);
179
211
  const result = await this.createOrUpdateProductInQls(ctx, client, variant, existingQlsProduct ?? null);
180
212
  if (result === 'created') {
181
- createdInQls += 1;
213
+ createdInQls.push(variant);
182
214
  }
183
215
  else if (result === 'updated') {
184
- updatedInQls += 1;
216
+ updatedInQls.push(variant);
185
217
  }
186
218
  }
187
219
  catch (e) {
188
220
  const error = (0, catch_unknown_1.asError)(e);
189
221
  core_1.Logger.error(`Error syncing variant ${variantId} to QLS: ${error.message}`, constants_1.loggerCtx, error.stack);
190
- failedCount += 1;
222
+ failed.push({ id: variantId });
191
223
  }
192
224
  }
193
225
  return {
194
226
  updatedInQls,
195
227
  createdInQls,
196
- updatedStock: 0,
197
- failed: failedCount,
228
+ updatedStock: [],
229
+ failed,
198
230
  };
199
231
  }
200
232
  /**
@@ -219,7 +251,7 @@ let QlsProductService = class QlsProductService {
219
251
  action: 'sync-products',
220
252
  ctx: ctx.serialize(),
221
253
  productVariantIds,
222
- }, { retries: 5 });
254
+ }, { retries: 1 });
223
255
  }
224
256
  /**
225
257
  * Update the stock level for a variant based on the given available stock
@@ -241,6 +273,10 @@ let QlsProductService = class QlsProductService {
241
273
  * Determines if a product needs to be created or updated in QLS based on the given variant and existing QLS product.
242
274
  */
243
275
  async createOrUpdateProductInQls(ctx, client, variant, existingProduct) {
276
+ if (await this.options.excludeVariantFromSync?.(ctx, new core_1.Injector(this.moduleRef), variant)) {
277
+ core_1.Logger.info(`Variant '${variant.sku}' excluded from sync to QLS.`, constants_1.loggerCtx);
278
+ return 'not-changed';
279
+ }
244
280
  let qlsProduct = existingProduct;
245
281
  let createdOrUpdated = 'not-changed';
246
282
  const { additionalEANs, ...additionalVariantFields } = this.options.getAdditionalVariantFields(ctx, variant);
@@ -292,7 +328,9 @@ let QlsProductService = class QlsProductService {
292
328
  existingEans: existingAdditionalEANs,
293
329
  desiredEans: additionalEANs,
294
330
  });
295
- if (!eansToUpdate || eansToUpdate.eansToAdd.length === 0 && eansToUpdate.eansToRemove.length === 0) {
331
+ if (!eansToUpdate ||
332
+ (eansToUpdate.eansToAdd.length === 0 &&
333
+ eansToUpdate.eansToRemove.length === 0)) {
296
334
  // No updates needed
297
335
  return false;
298
336
  }
@@ -366,7 +404,7 @@ let QlsProductService = class QlsProductService {
366
404
  * Update stock level for a variant based on the given available stock
367
405
  */
368
406
  async updateStock(ctx, variantId, availableStock) {
369
- if (this.options.disableStockSync) {
407
+ if (!this.options.synchronizeStockLevels) {
370
408
  core_1.Logger.warn(`Stock sync disabled. Not updating stock for variant '${variantId}'`, constants_1.loggerCtx);
371
409
  return;
372
410
  }
@@ -393,5 +431,6 @@ exports.QlsProductService = QlsProductService = __decorate([
393
431
  core_1.StockLocationService,
394
432
  core_1.EventBus,
395
433
  core_1.ListQueryBuilder,
396
- core_1.ProductPriceApplicator])
434
+ core_1.ProductPriceApplicator,
435
+ core_2.ModuleRef])
397
436
  ], QlsProductService);
package/dist/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ID, Injector, Order, ProductVariant, RequestContext, SerializedRequestContext } from '@vendure/core';
2
- import { CustomValue, FulfillmentProductInput } from './lib/client-types';
2
+ import { CustomValue, FulfillmentOrderInput, FulfillmentProductInput } from './lib/client-types';
3
3
  export interface QlsPluginOptions {
4
4
  /**
5
5
  * Get the QLS client config for the current channel based on given context
@@ -21,10 +21,32 @@ export interface QlsPluginOptions {
21
21
  */
22
22
  webhookSecret: string;
23
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
24
+ * Allows you to disable the pulling in of stock levels from QLS. When disabled, stock in Vendure will not be modified based on QLS stock levels.
25
+ * Defaults to true.
26
26
  */
27
- disableStockSync?: boolean;
27
+ synchronizeStockLevels?: boolean;
28
+ /**
29
+ * Allows you to disable the automatic pushing of orders to QLS. You can still push orders manually via the Admin UI.
30
+ * Defaults to true.
31
+ */
32
+ autoPushOrders?: boolean;
33
+ /**
34
+ * Allows you to define a date from when the order should be processed by QLS.
35
+ * You can for example make orders processable 2 hours from now, so that you can still edit the order in QLS
36
+ * Defaults to now.
37
+ */
38
+ processOrderFrom?: (ctx: RequestContext, order: Order) => Date | Promise<Date>;
39
+ /**
40
+ * Optional function to determine if a product variant should be excluded from syncing to QLS.
41
+ * Return true to exclude the variant from sync, false or undefined to include it.
42
+ */
43
+ excludeVariantFromSync?: (ctx: RequestContext, injector: Injector, variant: ProductVariant) => boolean | Promise<boolean>;
44
+ /**
45
+ * Optional function to customize the receiver contact details when creating a QLS order.
46
+ * Allows you to set different fields or override default mapping from the order's shipping address and customer.
47
+ * If not provided, default mapping will be used.
48
+ */
49
+ getReceiverContact?: (ctx: RequestContext, order: Order) => FulfillmentOrderInput['receiver_contact'] | undefined;
28
50
  }
29
51
  /**
30
52
  * Additional fields for a product variant that are used to create or update a product in QLS
@@ -35,7 +57,9 @@ export type AdditionalVariantFields = Partial<FulfillmentProductInput & {
35
57
  }>;
36
58
  export interface AdditionalOrderFields {
37
59
  servicepoint_code?: string;
38
- delivery_options?: string[];
60
+ delivery_options?: {
61
+ tag: string;
62
+ }[];
39
63
  custom_values?: CustomValue[];
40
64
  }
41
65
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pinelab/vendure-plugin-qls-fulfillment",
3
- "version": "1.0.0-beta.9",
3
+ "version": "1.1.0",
4
4
  "description": "Vendure plugin to fulfill orders via QLS.",
5
5
  "keywords": [
6
6
  "fulfillment",
@@ -31,5 +31,6 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "catch-unknown": "^2.0.0"
34
- }
34
+ },
35
+ "gitHead": "8f5d76680b66b12b9fb3732a4205b4ce20686076"
35
36
  }