@pinelab/vendure-plugin-qls-fulfillment 1.1.2 → 1.2.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,15 @@
1
+ # 1.2.0 (2026-01-28)
2
+
3
+ - Prevent accidently pushing orders multiple times by checking if the order is already synced to QLS.
4
+ - Store combination of QLS order id and Vendure order id to prevent duplicate orders in QLS.
5
+ - Emit event for failed product pushes, and log it as a warning instead of an error.
6
+ - Store failed products as scheduled task data so they can be viewed in the Admin UI.
7
+ - Allow specifying UI tab name where the QLS product ID custom field is shown on ProductVariant page.
8
+
9
+ # 1.1.3 (2026-01-28)
10
+
11
+ - Gracefully handle missing variants from incoming webhooks by logging instead of throwing an error.
12
+
1
13
  # 1.1.2 (2026-01-14)
2
14
 
3
15
  - Run scheduled full sync without job queue, because scheduled tasks already run in the worker only.
package/README.md CHANGED
@@ -78,7 +78,7 @@ mutation {
78
78
  }
79
79
  ```
80
80
 
81
- ## Monitoring failed orders
81
+ ## Monitoring failed orders and product syncs
82
82
 
83
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
84
 
@@ -100,6 +100,8 @@ this.eventBus.ofType(QlsOrderFailedEvent).subscribe((event) => {
100
100
  });
101
101
  ```
102
102
 
103
+ For product syncs, the same approach can be used, but with the `QlsProductSyncFailedEvent` instead.
104
+
103
105
  ## Manually pushing orders to QLS
104
106
 
105
107
  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.
@@ -6,6 +6,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
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
+ extend type Order {
10
+ """
11
+ QLS order id(s) for this Vendure order.
12
+ """
13
+ qlsOrderIds: [String!]!
14
+ }
15
+
9
16
  extend type Mutation {
10
17
  """
11
18
  Trigger a sync to create or update all products in Vendure to QLS, and pull in stock levels from QLS.
@@ -29,6 +29,11 @@ export type Mutation = {
29
29
  export type MutationPushOrderToQlsArgs = {
30
30
  orderId: Scalars['ID'];
31
31
  };
32
+ export type Order = {
33
+ __typename?: 'Order';
34
+ /** QLS order id(s) for this Vendure order. */
35
+ qlsOrderIds: Array<Scalars['String']>;
36
+ };
32
37
  export type QlsServicePoint = {
33
38
  __typename?: 'QlsServicePoint';
34
39
  address: QlsServicePointAddress;
@@ -1,11 +1,12 @@
1
- import { RequestContext } from '@vendure/core';
1
+ import { Order, RequestContext } from '@vendure/core';
2
+ import { QlsOrderService } from '../services/qls-order.service';
2
3
  import { QlsProductService } from '../services/qls-product.service';
3
4
  import { MutationPushOrderToQlsArgs } from './generated/graphql';
4
- import { QlsOrderService } from '../services/qls-order.service';
5
5
  export declare class QlsAdminResolver {
6
6
  private qlsProductService;
7
7
  private qlsOrderService;
8
8
  constructor(qlsProductService: QlsProductService, qlsOrderService: QlsOrderService);
9
+ qlsOrderIds(ctx: RequestContext, order: Order): Promise<string[]>;
9
10
  triggerQlsProductSync(ctx: RequestContext): Promise<boolean>;
10
11
  pushOrderToQls(ctx: RequestContext, input: MutationPushOrderToQlsArgs): Promise<string>;
11
12
  }
@@ -15,23 +15,38 @@ Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.QlsAdminResolver = void 0;
16
16
  const graphql_1 = require("@nestjs/graphql");
17
17
  const core_1 = require("@vendure/core");
18
- const qls_product_service_1 = require("../services/qls-product.service");
19
- const qls_order_service_1 = require("../services/qls-order.service");
20
18
  const permissions_1 = require("../config/permissions");
19
+ const qls_order_service_1 = require("../services/qls-order.service");
20
+ const qls_product_service_1 = require("../services/qls-product.service");
21
21
  let QlsAdminResolver = class QlsAdminResolver {
22
22
  constructor(qlsProductService, qlsOrderService) {
23
23
  this.qlsProductService = qlsProductService;
24
24
  this.qlsOrderService = qlsOrderService;
25
25
  }
26
+ async qlsOrderIds(ctx, order) {
27
+ return this.qlsOrderService.getQlsOrderIdsForOrder(ctx, order.id);
28
+ }
26
29
  async triggerQlsProductSync(ctx) {
27
30
  await this.qlsProductService.triggerFullSync(ctx);
28
31
  return true;
29
32
  }
30
33
  async pushOrderToQls(ctx, input) {
31
- return await this.qlsOrderService.pushOrderToQls(ctx, input.orderId);
34
+ return await this.qlsOrderService.pushOrderToQls(ctx, input.orderId, true);
32
35
  }
33
36
  };
34
37
  exports.QlsAdminResolver = QlsAdminResolver;
38
+ __decorate([
39
+ (0, graphql_1.ResolveField)(),
40
+ (0, graphql_1.Resolver)('Order'),
41
+ (0, core_1.Allow)(permissions_1.qlsPushOrderPermission.Permission),
42
+ (0, core_1.Allow)(core_1.Permission.UpdateAdministrator),
43
+ __param(0, (0, core_1.Ctx)()),
44
+ __param(1, (0, graphql_1.Parent)()),
45
+ __metadata("design:type", Function),
46
+ __metadata("design:paramtypes", [core_1.RequestContext,
47
+ core_1.Order]),
48
+ __metadata("design:returntype", Promise)
49
+ ], QlsAdminResolver.prototype, "qlsOrderIds", null);
35
50
  __decorate([
36
51
  (0, graphql_1.Mutation)(),
37
52
  (0, core_1.Transaction)(),
@@ -19,7 +19,12 @@ exports.qlsSyncAllProductsTask = new core_1.ScheduledTask({
19
19
  apiType: 'admin',
20
20
  });
21
21
  const channels = await injector.get(core_1.ChannelService).findAll(ctx);
22
- let fullSyncCompleted = 0;
22
+ const aggregatedResult = {
23
+ updatedInQls: 0,
24
+ createdInQls: 0,
25
+ updatedStock: 0,
26
+ failed: 0,
27
+ };
23
28
  for (const channel of channels.items) {
24
29
  // Create ctx for channel
25
30
  const channelCtx = new core_1.RequestContext({
@@ -35,11 +40,12 @@ exports.qlsSyncAllProductsTask = new core_1.ScheduledTask({
35
40
  core_1.Logger.info(`QLS not enabled for channel ${channel.token}`, constants_1.loggerCtx);
36
41
  continue;
37
42
  }
38
- await qlsProductService.runFullSync(channelCtx);
39
- fullSyncCompleted += 1;
43
+ const result = await qlsProductService.runFullSync(channelCtx);
44
+ aggregatedResult.updatedInQls += result.updatedInQls.length;
45
+ aggregatedResult.createdInQls += result.createdInQls.length;
46
+ aggregatedResult.updatedStock += result.updatedStock.length;
47
+ aggregatedResult.failed += result.failed.length;
40
48
  }
41
- return {
42
- message: `Finnished full sync for ${fullSyncCompleted} channels`,
43
- };
49
+ return aggregatedResult;
44
50
  },
45
51
  });
@@ -6,8 +6,8 @@ declare module '@vendure/core' {
6
6
  interface CustomOrderFields {
7
7
  qlsServicePointId?: string;
8
8
  qlsServicePointDetails?: string;
9
- syncedToQls?: boolean;
10
9
  }
11
10
  }
12
- export declare const variantCustomFields: CustomFieldConfig[];
11
+ /** Returns variant custom fields with the given Admin UI tab name. */
12
+ export declare function getVariantCustomFields(uiTab: string): CustomFieldConfig[];
13
13
  export declare const orderCustomFields: CustomFieldConfig[];
@@ -1,18 +1,22 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.orderCustomFields = exports.variantCustomFields = void 0;
3
+ exports.orderCustomFields = void 0;
4
+ exports.getVariantCustomFields = getVariantCustomFields;
4
5
  const core_1 = require("@vendure/core");
5
- exports.variantCustomFields = [
6
- {
7
- name: 'qlsProductId',
8
- type: 'string',
9
- label: [{ value: 'QLS Product ID', languageCode: core_1.LanguageCode.en }],
10
- nullable: true,
11
- public: false,
12
- readonly: true,
13
- ui: { tab: 'QLS' },
14
- },
15
- ];
6
+ /** Returns variant custom fields with the given Admin UI tab name. */
7
+ function getVariantCustomFields(uiTab) {
8
+ return [
9
+ {
10
+ name: 'qlsProductId',
11
+ type: 'string',
12
+ label: [{ value: 'QLS Product ID', languageCode: core_1.LanguageCode.en }],
13
+ nullable: true,
14
+ public: false,
15
+ readonly: true,
16
+ ui: { tab: uiTab },
17
+ },
18
+ ];
19
+ }
16
20
  exports.orderCustomFields = [
17
21
  {
18
22
  name: 'qlsServicePointId',
@@ -23,28 +27,6 @@ exports.orderCustomFields = [
23
27
  readonly: false,
24
28
  ui: { tab: 'QLS' },
25
29
  },
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
30
  {
49
31
  name: 'qlsServicePointDetails',
50
32
  type: 'string',
@@ -0,0 +1,9 @@
1
+ import { DeepPartial, ID, VendureEntity } from '@vendure/core';
2
+ /**
3
+ * Simple entity to keep track of what QLS orders were created for what Vendure orders.
4
+ */
5
+ export declare class QlsOrderEntity extends VendureEntity {
6
+ constructor(input?: DeepPartial<QlsOrderEntity>);
7
+ qlsOrderId: ID;
8
+ vendureOrderId: ID;
9
+ }
@@ -0,0 +1,35 @@
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
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.QlsOrderEntity = void 0;
13
+ const core_1 = require("@vendure/core");
14
+ const typeorm_1 = require("typeorm");
15
+ /**
16
+ * Simple entity to keep track of what QLS orders were created for what Vendure orders.
17
+ */
18
+ let QlsOrderEntity = class QlsOrderEntity extends core_1.VendureEntity {
19
+ constructor(input) {
20
+ super(input);
21
+ }
22
+ };
23
+ exports.QlsOrderEntity = QlsOrderEntity;
24
+ __decorate([
25
+ (0, typeorm_1.Column)({ type: 'text', nullable: false, unique: true }),
26
+ __metadata("design:type", Object)
27
+ ], QlsOrderEntity.prototype, "qlsOrderId", void 0);
28
+ __decorate([
29
+ (0, typeorm_1.Column)({ type: 'text', nullable: false }),
30
+ __metadata("design:type", Object)
31
+ ], QlsOrderEntity.prototype, "vendureOrderId", void 0);
32
+ exports.QlsOrderEntity = QlsOrderEntity = __decorate([
33
+ (0, typeorm_1.Entity)(),
34
+ __metadata("design:paramtypes", [Object])
35
+ ], QlsOrderEntity);
@@ -22,11 +22,13 @@ const constants_1 = require("./constants");
22
22
  const custom_fields_1 = require("./custom-fields");
23
23
  const qls_order_service_1 = require("./services/qls-order.service");
24
24
  const qls_product_service_1 = require("./services/qls-product.service");
25
+ const qls_order_entity_entity_1 = require("./entities/qls-order-entity.entity");
25
26
  let QlsPlugin = QlsPlugin_1 = class QlsPlugin {
26
27
  static init(options) {
27
28
  this.options = {
28
29
  synchronizeStockLevels: true,
29
30
  autoPushOrders: true,
31
+ qlsProductIdUiTab: 'QLS',
30
32
  ...options,
31
33
  };
32
34
  return QlsPlugin_1;
@@ -53,7 +55,7 @@ exports.QlsPlugin = QlsPlugin = QlsPlugin_1 = __decorate([
53
55
  configuration: (config) => {
54
56
  config.authOptions.customPermissions.push(permissions_1.qlsFullSyncPermission);
55
57
  config.authOptions.customPermissions.push(permissions_1.qlsPushOrderPermission);
56
- config.customFields.ProductVariant.push(...custom_fields_1.variantCustomFields);
58
+ config.customFields.ProductVariant.push(...(0, custom_fields_1.getVariantCustomFields)(QlsPlugin.options?.qlsProductIdUiTab ?? 'QLS'));
57
59
  config.customFields.Order.push(...custom_fields_1.orderCustomFields);
58
60
  return config;
59
61
  },
@@ -66,5 +68,6 @@ exports.QlsPlugin = QlsPlugin = QlsPlugin_1 = __decorate([
66
68
  schema: api_extensions_1.shopApiExtensions,
67
69
  resolvers: [qls_shop_resolver_1.QlsShopResolver],
68
70
  },
71
+ entities: [qls_order_entity_entity_1.QlsOrderEntity],
69
72
  })
70
73
  ], QlsPlugin);
@@ -1,6 +1,6 @@
1
1
  import { OnApplicationBootstrap, OnModuleInit } from '@nestjs/common';
2
2
  import { ModuleRef } from '@nestjs/core';
3
- import { EventBus, ID, Job, JobQueueService, OrderService, RequestContext } from '@vendure/core';
3
+ import { EventBus, ID, Job, JobQueueService, OrderService, RequestContext, TransactionalConnection } from '@vendure/core';
4
4
  import { QlsServicePoint, QlsServicePointSearchInput } from '../api/generated/graphql';
5
5
  import { IncomingOrderWebhook } from '../lib/client-types';
6
6
  import { QlsOrderJobData, QlsPluginOptions } from '../types';
@@ -10,8 +10,9 @@ export declare class QlsOrderService implements OnModuleInit, OnApplicationBoots
10
10
  private eventBus;
11
11
  private orderService;
12
12
  private moduleRef;
13
+ private readonly connection;
13
14
  private orderJobQueue;
14
- constructor(options: QlsPluginOptions, jobQueueService: JobQueueService, eventBus: EventBus, orderService: OrderService, moduleRef: ModuleRef);
15
+ constructor(options: QlsPluginOptions, jobQueueService: JobQueueService, eventBus: EventBus, orderService: OrderService, moduleRef: ModuleRef, connection: TransactionalConnection);
15
16
  onApplicationBootstrap(): void;
16
17
  onModuleInit(): Promise<void>;
17
18
  /**
@@ -22,13 +23,19 @@ export declare class QlsOrderService implements OnModuleInit, OnApplicationBoots
22
23
  /**
23
24
  * Push an order to QLS by id.
24
25
  * Returns a human-readable message describing the result of the operation (Used as job result).
26
+ *
27
+ * `force` can be used to force the push of an order even if one already exists in QLS.
25
28
  */
26
- pushOrderToQls(ctx: RequestContext, orderId: ID): Promise<string>;
29
+ pushOrderToQls(ctx: RequestContext, orderId: ID, force?: boolean): Promise<string>;
27
30
  /**
28
31
  * Update the status of an order in QLS based on the given order code and status
29
32
  */
30
33
  handleOrderStatusUpdate(ctx: RequestContext, body: IncomingOrderWebhook): Promise<void>;
31
34
  triggerPushOrder(ctx: RequestContext, orderId: ID, orderCode?: string): Promise<Job<QlsOrderJobData> | undefined>;
35
+ /**
36
+ * Get QLS order id(s) for a Vendure order (for Order.qlsOrderIds field).
37
+ */
38
+ getQlsOrderIdsForOrder(ctx: RequestContext, orderId: ID): Promise<string[]>;
32
39
  getServicePoints(ctx: RequestContext, input: QlsServicePointSearchInput): Promise<QlsServicePoint[]>;
33
40
  private getVendureOrderState;
34
41
  }
@@ -24,13 +24,15 @@ 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 qls_order_failed_event_1 = require("./qls-order-failed-event");
27
+ const qls_order_entity_entity_1 = require("../entities/qls-order-entity.entity");
27
28
  let QlsOrderService = class QlsOrderService {
28
- constructor(options, jobQueueService, eventBus, orderService, moduleRef) {
29
+ constructor(options, jobQueueService, eventBus, orderService, moduleRef, connection) {
29
30
  this.options = options;
30
31
  this.jobQueueService = jobQueueService;
31
32
  this.eventBus = eventBus;
32
33
  this.orderService = orderService;
33
34
  this.moduleRef = moduleRef;
35
+ this.connection = connection;
34
36
  }
35
37
  onApplicationBootstrap() {
36
38
  // Listen for OrderPlacedEvent and add a job to the queue
@@ -79,8 +81,10 @@ let QlsOrderService = class QlsOrderService {
79
81
  /**
80
82
  * Push an order to QLS by id.
81
83
  * Returns a human-readable message describing the result of the operation (Used as job result).
84
+ *
85
+ * `force` can be used to force the push of an order even if one already exists in QLS.
82
86
  */
83
- async pushOrderToQls(ctx, orderId) {
87
+ async pushOrderToQls(ctx, orderId, force = false) {
84
88
  const client = await (0, qls_client_1.getQlsClient)(ctx, this.options);
85
89
  if (!client) {
86
90
  // Jobs are only added when QLS is enabled for the channel, so if we cant get a client here, something is wrong
@@ -90,8 +94,17 @@ let QlsOrderService = class QlsOrderService {
90
94
  if (!order) {
91
95
  throw new Error(`No order with id ${orderId} not found`);
92
96
  }
93
- if (order.customFields.syncedToQls) {
94
- throw new core_2.UserInputError(`Order '${order.code}' has already been synced to QLS`);
97
+ if (!force) {
98
+ const existingQlsOrder = await this.connection
99
+ .getRepository(ctx, qls_order_entity_entity_1.QlsOrderEntity)
100
+ .findOne({
101
+ where: {
102
+ vendureOrderId: orderId,
103
+ },
104
+ });
105
+ if (existingQlsOrder) {
106
+ throw new core_2.UserInputError(`Order '${order.code}' has already been synced to QLS`);
107
+ }
95
108
  }
96
109
  try {
97
110
  // Map variants to QLS products
@@ -159,32 +172,49 @@ let QlsOrderService = class QlsOrderService {
159
172
  };
160
173
  const result = await client.createFulfillmentOrder(qlsOrder);
161
174
  core_2.Logger.info(`Successfully created order '${order.code}' in QLS with id '${result.id}'`, constants_1.loggerCtx);
162
- await this.orderService.addNoteToOrder(ctx, {
175
+ // Add note but catch any errors, because we don't want the job to fail and retry when adding a note fails
176
+ await this.orderService
177
+ .addNoteToOrder(ctx, {
163
178
  id: orderId,
164
179
  isPublic: false,
165
180
  note: `Created order '${result.id}' in QLS`,
181
+ })
182
+ .catch((e) => {
183
+ const error = (0, catch_unknown_1.asError)(e);
184
+ core_2.Logger.error(`Error adding note to order '${order.code}': ${error.message}`, constants_1.loggerCtx, error.stack);
166
185
  });
167
- // Delayed custom field update to prevent race conditions: OrderPlacedEvent is emitted before transition to PaymentSettled is complete
168
- await new Promise((resolve) => setTimeout(resolve, 5000));
169
- await this.orderService
170
- .updateCustomFields(ctx, orderId, {
171
- syncedToQls: true,
186
+ await this.connection
187
+ .getRepository(ctx, qls_order_entity_entity_1.QlsOrderEntity)
188
+ .save({
189
+ qlsOrderId: result.id,
190
+ vendureOrderId: orderId,
172
191
  })
173
192
  .catch((e) => {
174
- // catch any errors, because we don't want the job to fail and retry when custom field update fails
193
+ // Catch any errors, because we don't want the job to fail and retry when custom field update fails
175
194
  const error = (0, catch_unknown_1.asError)(e);
176
- core_2.Logger.error(`Error updating custom field 'syncedToQls: true' for order '${order.code}': ${error.message}`, constants_1.loggerCtx, error.stack);
195
+ core_2.Logger.error(`Error saving QLS order entity for order '${order.code}': ${error.message}`, constants_1.loggerCtx, error.stack);
177
196
  });
178
197
  return `Order '${order.code}' created in QLS with id '${result.id}'`;
179
198
  }
180
199
  catch (e) {
181
200
  const error = (0, catch_unknown_1.asError)(e);
182
- await this.orderService.addNoteToOrder(ctx, {
201
+ await this.orderService
202
+ .addNoteToOrder(ctx, {
183
203
  id: orderId,
184
204
  isPublic: false,
185
205
  note: `Failed to create order '${order.code}' in QLS: ${error.message}`,
206
+ })
207
+ .catch((e) => {
208
+ const error = (0, catch_unknown_1.asError)(e);
209
+ core_2.Logger.error(`Error adding note to order '${order.code}': ${error.message}`, constants_1.loggerCtx, error.stack);
210
+ });
211
+ await this.eventBus
212
+ .publish(new qls_order_failed_event_1.QlsOrderFailedEvent(ctx, order, new Date(), error.message))
213
+ .catch((e) => {
214
+ // Don't swallow original error, so catch and log this one
215
+ const error = (0, catch_unknown_1.asError)(e);
216
+ core_2.Logger.error(`Error publishing QlsOrderFailedEvent for order '${order.code}': ${error.message}`, constants_1.loggerCtx, error.stack);
186
217
  });
187
- await this.eventBus.publish(new qls_order_failed_event_1.QlsOrderFailedEvent(ctx, order, new Date(), error.message));
188
218
  throw error;
189
219
  }
190
220
  }
@@ -223,6 +253,17 @@ let QlsOrderService = class QlsOrderService {
223
253
  orderId,
224
254
  }, { retries: 3 });
225
255
  }
256
+ /**
257
+ * Get QLS order id(s) for a Vendure order (for Order.qlsOrderIds field).
258
+ */
259
+ async getQlsOrderIdsForOrder(ctx, orderId) {
260
+ const entities = await this.connection
261
+ .getRepository(ctx, qls_order_entity_entity_1.QlsOrderEntity)
262
+ .find({
263
+ where: { vendureOrderId: orderId },
264
+ });
265
+ return entities.map((e) => String(e.qlsOrderId));
266
+ }
226
267
  async getServicePoints(ctx, input) {
227
268
  const client = await (0, qls_client_1.getQlsClient)(ctx, this.options);
228
269
  if (!client) {
@@ -252,5 +293,6 @@ exports.QlsOrderService = QlsOrderService = __decorate([
252
293
  __metadata("design:paramtypes", [Object, core_2.JobQueueService,
253
294
  core_2.EventBus,
254
295
  core_2.OrderService,
255
- core_1.ModuleRef])
296
+ core_1.ModuleRef,
297
+ core_2.TransactionalConnection])
256
298
  ], QlsOrderService);
@@ -24,6 +24,7 @@ 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 qls_variant_sync_failed_event_1 = require("./qls-variant-sync-failed-event");
27
28
  const core_2 = require("@nestjs/core");
28
29
  // Wait for 700ms to avoid rate limit of 500/5 minutes
29
30
  const waitToPreventRateLimit = () => new Promise((resolve) => setTimeout(resolve, 700));
@@ -141,6 +142,7 @@ let QlsProductService = class QlsProductService {
141
142
  const error = (0, catch_unknown_1.asError)(e);
142
143
  core_1.Logger.error(`Error creating or updating variant '${variant.sku}' in QLS: ${error.message}`, constants_1.loggerCtx, error.stack);
143
144
  failed.push(variant);
145
+ await this.eventBus.publish(new qls_variant_sync_failed_event_1.QlsVariantSyncFailedEvent(ctx, variant, new Date(), e));
144
146
  await waitToPreventRateLimit();
145
147
  }
146
148
  }
@@ -196,17 +198,18 @@ let QlsProductService = class QlsProductService {
196
198
  const createdInQls = [];
197
199
  const failed = [];
198
200
  for (const variantId of productVariantIds) {
201
+ const variant = await this.variantService.findOne(ctx, variantId, [
202
+ 'featuredAsset',
203
+ 'taxCategory',
204
+ 'channels',
205
+ 'product.featuredAsset',
206
+ ]);
207
+ if (!variant) {
208
+ // Can happen when a variant is deleted from Vendure after the job was triggered
209
+ core_1.Logger.info(`Variant with id ${variantId} not found. Not creating or updating product in QLS.`, constants_1.loggerCtx);
210
+ continue;
211
+ }
199
212
  try {
200
- const variant = await this.variantService.findOne(ctx, variantId, [
201
- 'featuredAsset',
202
- 'taxCategory',
203
- 'channels',
204
- 'product.featuredAsset',
205
- ]);
206
- if (!variant) {
207
- core_1.Logger.error(`Variant with id ${variantId} not found. Not creating or updating product in QLS.`, constants_1.loggerCtx);
208
- continue;
209
- }
210
213
  const existingQlsProduct = await client.getFulfillmentProductBySku(variant.sku);
211
214
  const result = await this.createOrUpdateProductInQls(ctx, client, variant, existingQlsProduct ?? null);
212
215
  if (result === 'created') {
@@ -218,8 +221,10 @@ let QlsProductService = class QlsProductService {
218
221
  }
219
222
  catch (e) {
220
223
  const error = (0, catch_unknown_1.asError)(e);
221
- core_1.Logger.error(`Error syncing variant ${variantId} to QLS: ${error.message}`, constants_1.loggerCtx, error.stack);
224
+ // Log as warning, because this is probably a functional mistake, i.e. duplicate barcodes or EANs
225
+ core_1.Logger.warn(`Error syncing variant ${variantId} (${variant.sku}) to QLS: ${error.message}`, constants_1.loggerCtx);
222
226
  failed.push({ id: variantId });
227
+ await this.eventBus.publish(new qls_variant_sync_failed_event_1.QlsVariantSyncFailedEvent(ctx, variant, new Date(), e));
223
228
  }
224
229
  }
225
230
  return {
@@ -261,13 +266,13 @@ let QlsProductService = class QlsProductService {
261
266
  filter: { sku: { eq: sku } },
262
267
  });
263
268
  if (!result.items.length) {
264
- throw new Error(`Variant with sku '${sku}' not found`);
269
+ return core_1.Logger.info(`Variant with sku '${sku}' not found, not updating stock`, constants_1.loggerCtx);
265
270
  }
266
271
  const variant = result.items[0];
267
272
  if (result.items.length > 1) {
268
273
  core_1.Logger.error(`Multiple variants found for sku '${sku}', using '${variant.id}'`, constants_1.loggerCtx);
269
274
  }
270
- return this.updateStock(ctx, variant.id, availableStock);
275
+ await this.updateStock(ctx, variant.id, availableStock);
271
276
  }
272
277
  /**
273
278
  * Determines if a product needs to be created or updated in QLS based on the given variant and existing QLS product.
@@ -0,0 +1,17 @@
1
+ import { ProductVariant, RequestContext, VendureEvent } from '@vendure/core';
2
+ /**
3
+ * Emitted when a variant failed to sync to QLS.
4
+ */
5
+ export declare class QlsVariantSyncFailedEvent extends VendureEvent {
6
+ ctx: RequestContext;
7
+ /** The variant that failed to sync (at least id, may be full variant). */
8
+ variant: Partial<ProductVariant>;
9
+ failedAt: Date;
10
+ /** The error that caused the sync to fail. */
11
+ fullError: unknown;
12
+ constructor(ctx: RequestContext,
13
+ /** The variant that failed to sync (at least id, may be full variant). */
14
+ variant: Partial<ProductVariant>, failedAt: Date,
15
+ /** The error that caused the sync to fail. */
16
+ fullError: unknown);
17
+ }
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.QlsVariantSyncFailedEvent = void 0;
4
+ const core_1 = require("@vendure/core");
5
+ /**
6
+ * Emitted when a variant failed to sync to QLS.
7
+ */
8
+ class QlsVariantSyncFailedEvent extends core_1.VendureEvent {
9
+ constructor(ctx,
10
+ /** The variant that failed to sync (at least id, may be full variant). */
11
+ variant, failedAt,
12
+ /** The error that caused the sync to fail. */
13
+ fullError) {
14
+ super();
15
+ this.ctx = ctx;
16
+ this.variant = variant;
17
+ this.failedAt = failedAt;
18
+ this.fullError = fullError;
19
+ }
20
+ }
21
+ exports.QlsVariantSyncFailedEvent = QlsVariantSyncFailedEvent;
package/dist/types.d.ts CHANGED
@@ -47,6 +47,11 @@ export interface QlsPluginOptions {
47
47
  * If not provided, default mapping will be used.
48
48
  */
49
49
  getReceiverContact?: (ctx: RequestContext, order: Order) => FulfillmentOrderInput['receiver_contact'] | undefined;
50
+ /**
51
+ * Admin UI tab name where the QLS Product ID custom field is shown on ProductVariant.
52
+ * Defaults to 'QLS'.
53
+ */
54
+ qlsProductIdUiTab?: string;
50
55
  }
51
56
  /**
52
57
  * Additional fields for a product variant that are used to create or update a product in QLS
@@ -1,4 +1,8 @@
1
- import { addActionBarDropdownMenuItem } from '@vendure/admin-ui/core';
1
+ import {
2
+ addActionBarDropdownMenuItem,
3
+ ModalService,
4
+ } from '@vendure/admin-ui/core';
5
+ import { firstValueFrom } from 'rxjs';
2
6
  import gql from 'graphql-tag';
3
7
 
4
8
  export default [
@@ -42,7 +46,39 @@ export default [
42
46
  icon: 'resistor',
43
47
  requiresPermission: ['QLSFullSync'],
44
48
  hasDivider: true,
45
- onClick: (_, { route, dataService, notificationService }) => {
49
+ onClick: async (
50
+ _,
51
+ { route, dataService, notificationService, injector }
52
+ ) => {
53
+ const orderId = route.snapshot.params.id;
54
+ const res = await firstValueFrom(
55
+ dataService.query(
56
+ gql`
57
+ query Order($orderId: ID!) {
58
+ order(id: $orderId) {
59
+ id
60
+ qlsOrderIds
61
+ }
62
+ }
63
+ `,
64
+ { orderId }
65
+ ).single$
66
+ );
67
+ if ((res as any).order.qlsOrderIds.length > 0) {
68
+ const modalService = injector.get(ModalService);
69
+ const confirmed = await firstValueFrom(
70
+ modalService.dialog({
71
+ title: 'Push order to QLS',
72
+ body: 'This order already exists in QLS. Are you sure you want to push it again?',
73
+ buttons: [
74
+ { type: 'secondary', label: 'Cancel', returnValue: false },
75
+ { type: 'primary', label: 'Push to QLS', returnValue: true },
76
+ ],
77
+ })
78
+ );
79
+ if (!confirmed) return;
80
+ }
81
+
46
82
  dataService
47
83
  .mutate(
48
84
  gql`
@@ -50,13 +86,10 @@ export default [
50
86
  pushOrderToQls(orderId: $orderId)
51
87
  }
52
88
  `,
53
- {
54
- orderId: route.snapshot.params.id,
55
- }
89
+ { orderId }
56
90
  )
57
91
  .subscribe({
58
92
  next: (result) => {
59
- console.log(result);
60
93
  notificationService.notify({
61
94
  message: (result as any).pushOrderToQls,
62
95
  type: 'success',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pinelab/vendure-plugin-qls-fulfillment",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "description": "Vendure plugin to fulfill orders via QLS.",
5
5
  "keywords": [
6
6
  "fulfillment",
@@ -32,5 +32,5 @@
32
32
  "dependencies": {
33
33
  "catch-unknown": "^2.0.0"
34
34
  },
35
- "gitHead": "2e1abb0be4b76adacee78bedad19af28961acfaa"
35
+ "gitHead": "60255d8c40e4068a9f87601fbcedea27719e8385"
36
36
  }