@things-factory/operato-hub 4.3.821 → 4.3.823

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.
@@ -7,87 +7,140 @@ import { ORDER_STATUS, OrderPackage } from '@things-factory/sales-base'
7
7
 
8
8
  const { PerformanceObserver, performance } = require('node:perf_hooks')
9
9
 
10
+ // Stable key for pg_try_advisory_lock — keeps two concurrent invocations of this
11
+ // resolver from contending on the same order_packages rows.
12
+ const ADVISORY_LOCK_KEY = 'powrup-get-airway-bill'
13
+
10
14
  export const powrupGetAirwayBillResolver = {
11
15
  async powrupGetAirwayBill(_: void, { }, context: any): Promise<void> {
16
+ // We deliberately do NOT wrap the resolver in a transaction. The previous
17
+ // implementation held a single long transaction across up to 100 external
18
+ // PowrupAPI HTTP calls, keeping row locks on every UPDATE'd order_packages
19
+ // row until the loop finished — minutes of contention against the packing
20
+ // UI, sellercraft updates, and any other path that touches those rows.
21
+ //
22
+ // Instead we use a single QueryRunner connection for the duration of the
23
+ // resolver, acquire a session-level advisory lock so concurrent runs skip
24
+ // early, and let each UPDATE auto-commit on its own (row lock held only for
25
+ // the duration of that single statement).
26
+ const queryRunner = getConnection().createQueryRunner()
27
+ let connected = false
28
+
12
29
  try {
13
- await getConnection().transaction(async tx => {
14
- try {
15
- const powrup: Powrup = await tx
16
- .getRepository(Powrup)
17
- .findOne({ where: { platform: Platform.POWRUP, active: true } })
30
+ await queryRunner.connect()
31
+ connected = true
18
32
 
19
- let orderPackageQb = await tx
20
- .getRepository(OrderPackage)
21
- .createQueryBuilder('opk')
22
- .innerJoinAndSelect('opk.releaseGood', 'rg')
23
- .innerJoinAndSelect('opk.domain', 'domain')
24
- .innerJoinAndSelect('opk.bizplace', 'bizplace')
25
- .innerJoinAndSelect('opk.creator', 'creator')
26
- .where(`rg.source = :source`, { source: ApplicationType.POWRUP })
27
- .andWhere(`rg.type = :rgType`, { rgType: 'b2c' })
28
- .andWhere(`rg.ref_order_id NOTNULL`)
29
- .andWhere(`(rg.lmd_option = false or rg.lmd_option ISNULL)`)
30
- .andWhere(`rg.status NOT IN (:...status) `, {
31
- status: [ORDER_STATUS.PACKING, ORDER_STATUS.CANCELLED, ORDER_STATUS.PENDING_CANCEL, ORDER_STATUS.DONE]
32
- })
33
- .andWhere(`opk.airway_bill ISNULL`)
34
- .orderBy(`rg.created_at`)
35
- .limit(100)
33
+ const lockResult: { acquired: boolean }[] = await queryRunner.query(
34
+ `SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS acquired`,
35
+ [ADVISORY_LOCK_KEY]
36
+ )
37
+ if (!lockResult?.[0]?.acquired) {
38
+ logger.info(`powrupGetAirwayBill: another instance is running, skipping`)
39
+ return
40
+ }
36
41
 
37
- let orderPackagesWithoutAirwayBill: OrderPackage[] = await orderPackageQb.getMany()
42
+ try {
43
+ const manager = queryRunner.manager
38
44
 
39
- if (orderPackagesWithoutAirwayBill?.length > 0) {
40
- for (let i = 0; i < orderPackagesWithoutAirwayBill.length; i++) {
41
- const orderPackage: OrderPackage = orderPackagesWithoutAirwayBill[i]
42
- performance.mark(`start:${orderPackage.releaseGood.refOrderId} - ${orderPackage.packageId}`)
43
- try {
44
- const {
45
- trackingNo,
46
- transporter,
47
- printLabels
48
- }: { trackingNo: string; transporter: string; printLabels: any[] } = await PowrupAPI.fetchDocument(
49
- powrup,
50
- {
51
- context: { state: { domain: null, user: null } },
52
- refOrderId: orderPackage.releaseGood.refOrderId,
53
- packageId: orderPackage.packageId
54
- }
55
- )
56
- performance.mark(`end:${orderPackage.releaseGood.refOrderId} - ${orderPackage.packageId}`)
57
- let updateInfo: any
58
- if (printLabels?.length) {
59
- printLabels.map(pl => {
60
- updateInfo = { ...updateInfo, airwayBill: pl.awbUrl }
61
- })
45
+ const powrup: Powrup = await manager.findOne(Powrup, {
46
+ where: { platform: Platform.POWRUP, active: true }
47
+ })
62
48
 
63
- updateInfo = {
64
- ...updateInfo,
65
- trackingNo: trackingNo,
66
- transporter: transporter
67
- }
49
+ const orderPackagesWithoutAirwayBill: OrderPackage[] = await manager
50
+ .createQueryBuilder(OrderPackage, 'opk')
51
+ .innerJoinAndSelect('opk.releaseGood', 'rg')
52
+ .innerJoinAndSelect('opk.domain', 'domain')
53
+ .innerJoinAndSelect('opk.bizplace', 'bizplace')
54
+ .innerJoinAndSelect('opk.creator', 'creator')
55
+ .where(`rg.source = :source`, { source: ApplicationType.POWRUP })
56
+ .andWhere(`rg.type = :rgType`, { rgType: 'b2c' })
57
+ .andWhere(`rg.ref_order_id NOTNULL`)
58
+ .andWhere(`(rg.lmd_option = false or rg.lmd_option ISNULL)`)
59
+ .andWhere(`rg.status NOT IN (:...status) `, {
60
+ status: [ORDER_STATUS.PACKING, ORDER_STATUS.CANCELLED, ORDER_STATUS.PENDING_CANCEL, ORDER_STATUS.DONE]
61
+ })
62
+ .andWhere(`opk.airway_bill ISNULL`)
63
+ .orderBy(`rg.created_at`)
64
+ .limit(100)
65
+ .getMany()
68
66
 
69
- await tx.getRepository(OrderPackage).update({ id: orderPackage.id }, { ...updateInfo })
67
+ if (orderPackagesWithoutAirwayBill?.length > 0) {
68
+ for (let i = 0; i < orderPackagesWithoutAirwayBill.length; i++) {
69
+ const orderPackage: OrderPackage = orderPackagesWithoutAirwayBill[i]
70
+ performance.mark(`start:${orderPackage.releaseGood.refOrderId} - ${orderPackage.packageId}`)
71
+ try {
72
+ const {
73
+ trackingNo,
74
+ transporter,
75
+ printLabels
76
+ }: { trackingNo: string; transporter: string; printLabels: any[] } = await PowrupAPI.fetchDocument(
77
+ powrup,
78
+ {
79
+ context: { state: { domain: null, user: null } },
80
+ refOrderId: orderPackage.releaseGood.refOrderId,
81
+ packageId: orderPackage.packageId
70
82
  }
71
- } catch (e) {
72
- performance.mark(`end:${orderPackage.releaseGood.refOrderId} - ${orderPackage.packageId}`)
73
- logger.error(`powrupGetAirwayBill[GetAirwayBill][Order Package ID: ${orderPackage.id}]: ${e}`)
74
- continue
75
- } finally {
76
- let perf = performance.measure(
77
- `powrupGetAirwayBill: ${orderPackage.releaseGood.refOrderId} - ${orderPackage.packageId}`,
78
- `start:${orderPackage.releaseGood.refOrderId} - ${orderPackage.packageId}`,
79
- `end:${orderPackage.releaseGood.refOrderId} - ${orderPackage.packageId}`
80
- )
81
- console.info(perf.toJSON())
83
+ )
84
+ performance.mark(`end:${orderPackage.releaseGood.refOrderId} - ${orderPackage.packageId}`)
85
+
86
+ if (printLabels?.length) {
87
+ let updateInfo: any
88
+ printLabels.map(pl => {
89
+ updateInfo = { ...updateInfo, airwayBill: pl.awbUrl }
90
+ })
91
+
92
+ updateInfo = {
93
+ ...updateInfo,
94
+ trackingNo: trackingNo,
95
+ transporter: transporter
96
+ }
97
+
98
+ // Auto-commit single-statement UPDATE on the QueryRunner's connection.
99
+ // Row lock held only for this UPDATE, not across the API call above.
100
+ await manager.update(OrderPackage, { id: orderPackage.id }, updateInfo)
82
101
  }
102
+ } catch (e) {
103
+ performance.mark(`end:${orderPackage.releaseGood.refOrderId} - ${orderPackage.packageId}`)
104
+ logger.error(`powrupGetAirwayBill[GetAirwayBill][Order Package ID: ${orderPackage.id}]: ${e}`)
105
+ continue
106
+ } finally {
107
+ let perf = performance.measure(
108
+ `powrupGetAirwayBill: ${orderPackage.releaseGood.refOrderId} - ${orderPackage.packageId}`,
109
+ `start:${orderPackage.releaseGood.refOrderId} - ${orderPackage.packageId}`,
110
+ `end:${orderPackage.releaseGood.refOrderId} - ${orderPackage.packageId}`
111
+ )
112
+ console.info(perf.toJSON())
83
113
  }
84
114
  }
85
- } catch (e) {
86
- logger.error(`powrupGetAirwayBill[GetAirwayBill]: ${e}`)
87
115
  }
88
- })
116
+ } catch (e) {
117
+ logger.error(`powrupGetAirwayBill[GetAirwayBill]: ${e}`)
118
+ } finally {
119
+ // Release the advisory lock so the next scheduled invocation can proceed.
120
+ // Wrapped in try/catch because release must not mask the original error.
121
+ try {
122
+ await queryRunner.query(`SELECT pg_advisory_unlock(hashtextextended($1, 0))`, [ADVISORY_LOCK_KEY])
123
+ } catch (releaseErr) {
124
+ logger.error(`powrupGetAirwayBill[AdvisoryUnlock]: ${releaseErr}`)
125
+ }
126
+ }
89
127
  } catch (e) {
90
128
  logger.error(`powrupGetAirwayBill[MainError]: ${e}`)
129
+ } finally {
130
+ // Defensive: clear any session-level advisory locks still held on this
131
+ // connection before returning it to the pool. Guards against the lock
132
+ // leaking if pg_advisory_unlock above failed (DB blip, statement timeout)
133
+ // and the pool then hands this connection to a future invocation.
134
+ // Skip when connect() itself failed — querying an unconnected QueryRunner
135
+ // would just throw with nothing to actually clean up.
136
+ if (connected) {
137
+ try {
138
+ await queryRunner.query(`SELECT pg_advisory_unlock_all()`)
139
+ } catch (unlockAllErr) {
140
+ logger.error(`powrupGetAirwayBill[AdvisoryUnlockAll]: ${unlockAllErr}`)
141
+ }
142
+ }
143
+ await queryRunner.release()
91
144
  }
92
145
  }
93
146
  }
@@ -5,60 +5,100 @@ import { logger } from '@things-factory/env'
5
5
  import { Platform, Powrup, PowrupAPI } from '@things-factory/integration-powrup'
6
6
  import { ORDER_STATUS, OrderPackage } from '@things-factory/sales-base'
7
7
 
8
+ const ADVISORY_LOCK_KEY = 'powrup-get-tracking-no'
9
+
8
10
  export const powrupGetTrackingNoResolver = {
9
11
  async powrupGetTrackingNo(_: void, { }, context: any): Promise<void> {
12
+ // Same fix as powrupGetAirwayBill: avoid wrapping the resolver in a long
13
+ // transaction across an external API call. Use a single QueryRunner with a
14
+ // session-level advisory lock for concurrency safety, and let each UPDATE
15
+ // auto-commit on its own.
16
+ const queryRunner = getConnection().createQueryRunner()
17
+ let connected = false
18
+
10
19
  try {
11
- await getConnection().transaction(async tx => {
12
- try {
13
- const powrup: Powrup = await tx
14
- .getRepository(Powrup)
15
- .findOne({ where: { platform: Platform.POWRUP, active: true } })
20
+ await queryRunner.connect()
21
+ connected = true
22
+
23
+ const lockResult: { acquired: boolean }[] = await queryRunner.query(
24
+ `SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS acquired`,
25
+ [ADVISORY_LOCK_KEY]
26
+ )
27
+ if (!lockResult?.[0]?.acquired) {
28
+ logger.info(`powrupGetTrackingNo: another instance is running, skipping`)
29
+ return
30
+ }
16
31
 
17
- let orderPackageQb = await tx
18
- .getRepository(OrderPackage)
19
- .createQueryBuilder('opk')
20
- .innerJoinAndSelect('opk.releaseGood', 'rg')
21
- .innerJoinAndSelect('opk.domain', 'domain')
22
- .innerJoinAndSelect('opk.bizplace', 'bizplace')
23
- .innerJoinAndSelect('opk.creator', 'creator')
24
- .where(`rg.source = :source`, { source: ApplicationType.POWRUP })
25
- .andWhere(`rg.type = :rgType`, { rgType: 'b2c' })
26
- .andWhere(`rg.ref_order_id NOTNULL`)
27
- .andWhere(`rg.status NOT IN(:...status) `, {
28
- status: [ORDER_STATUS.CANCELLED, ORDER_STATUS.PENDING_CANCEL]
29
- })
30
- .andWhere(`opk.tracking_no IS NULL`)
31
- .andWhere(`opk.airway_bill NOTNULL`)
32
- .andWhere(`opk.package_id NOTNULL`)
33
- .limit(500)
32
+ try {
33
+ const manager = queryRunner.manager
34
34
 
35
- let orderPackagesWithoutTrackingNo: OrderPackage[] = await orderPackageQb.getMany()
35
+ const powrup: Powrup = await manager.findOne(Powrup, {
36
+ where: { platform: Platform.POWRUP, active: true }
37
+ })
36
38
 
37
- if (orderPackagesWithoutTrackingNo?.length > 0) {
38
- // Combine all package id into packageIds
39
- let orderPackageIds: any = orderPackagesWithoutTrackingNo.map(op => op.packageId)
39
+ const orderPackagesWithoutTrackingNo: OrderPackage[] = await manager
40
+ .createQueryBuilder(OrderPackage, 'opk')
41
+ .innerJoinAndSelect('opk.releaseGood', 'rg')
42
+ .innerJoinAndSelect('opk.domain', 'domain')
43
+ .innerJoinAndSelect('opk.bizplace', 'bizplace')
44
+ .innerJoinAndSelect('opk.creator', 'creator')
45
+ .where(`rg.source = :source`, { source: ApplicationType.POWRUP })
46
+ .andWhere(`rg.type = :rgType`, { rgType: 'b2c' })
47
+ .andWhere(`rg.ref_order_id NOTNULL`)
48
+ .andWhere(`rg.status NOT IN(:...status) `, {
49
+ status: [ORDER_STATUS.CANCELLED, ORDER_STATUS.PENDING_CANCEL]
50
+ })
51
+ .andWhere(`opk.tracking_no IS NULL`)
52
+ .andWhere(`opk.airway_bill NOTNULL`)
53
+ .andWhere(`opk.package_id NOTNULL`)
54
+ .limit(500)
55
+ .getMany()
40
56
 
41
- // Call API with multiple package ids in one call
42
- const trackingNos: any = await PowrupAPI.getTrackingNo(powrup, {
43
- context: { state: { domain: null, user: null } },
44
- packageIds: orderPackageIds
45
- })
57
+ if (orderPackagesWithoutTrackingNo?.length > 0) {
58
+ // Batch the API call for all package IDs in one shot.
59
+ const orderPackageIds: any = orderPackagesWithoutTrackingNo.map(op => op.packageId)
60
+ const trackingNos: any = await PowrupAPI.getTrackingNo(powrup, {
61
+ context: { state: { domain: null, user: null } },
62
+ packageIds: orderPackageIds
63
+ })
46
64
 
47
- if (trackingNos?.length) {
48
- await Promise.all(
49
- trackingNos.map(async tn => {
50
- const { packageId, trackingNo } = tn
51
- await tx.getRepository(OrderPackage).update({ packageId: packageId }, { trackingNo: trackingNo })
52
- })
53
- )
65
+ if (trackingNos?.length) {
66
+ // Run UPDATEs sequentially as auto-commits. Parallel saves on the same
67
+ // QueryRunner connection are not safe (single connection), and parallel
68
+ // saves across separate connections gain little because the rows are
69
+ // independent and each UPDATE is fast.
70
+ for (const tn of trackingNos) {
71
+ const { packageId, trackingNo } = tn
72
+ await manager.update(OrderPackage, { packageId }, { trackingNo })
54
73
  }
55
74
  }
56
- } catch (e) {
57
- logger.error(`powrupGetTrackingNo[GetTrackingNo]: ${e}`)
58
75
  }
59
- })
76
+ } catch (e) {
77
+ logger.error(`powrupGetTrackingNo[GetTrackingNo]: ${e}`)
78
+ } finally {
79
+ try {
80
+ await queryRunner.query(`SELECT pg_advisory_unlock(hashtextextended($1, 0))`, [ADVISORY_LOCK_KEY])
81
+ } catch (releaseErr) {
82
+ logger.error(`powrupGetTrackingNo[AdvisoryUnlock]: ${releaseErr}`)
83
+ }
84
+ }
60
85
  } catch (e) {
61
86
  logger.error(`powrupGetTrackingNo[MainError]: ${e}`)
87
+ } finally {
88
+ // Defensive: clear any session-level advisory locks still held on this
89
+ // connection before returning it to the pool. Guards against the lock
90
+ // leaking if pg_advisory_unlock above failed (DB blip, statement timeout)
91
+ // and the pool then hands this connection to a future invocation.
92
+ // Skip when connect() itself failed — querying an unconnected QueryRunner
93
+ // would just throw with nothing to actually clean up.
94
+ if (connected) {
95
+ try {
96
+ await queryRunner.query(`SELECT pg_advisory_unlock_all()`)
97
+ } catch (unlockAllErr) {
98
+ logger.error(`powrupGetTrackingNo[AdvisoryUnlockAll]: ${unlockAllErr}`)
99
+ }
100
+ }
101
+ await queryRunner.release()
62
102
  }
63
103
  }
64
104
  }
@@ -21,8 +21,11 @@ export const Mutation = /* GraphQL */ `
21
21
  sellercraftPackMarketplaceOrder: Boolean @transaction
22
22
  sellercraftInitiateShipment: Boolean @transaction
23
23
  powrupPackOrder: Boolean @transaction
24
- powrupGetTrackingNo: Boolean @transaction
25
- powrupGetAirwayBill: Boolean @transaction
24
+ # Intentionally NOT @transaction — these resolvers manage their own QueryRunner
25
+ # to avoid holding row locks on order_packages across external Powrup API calls.
26
+ # See powrup-get-airway-bill.ts and powrup-get-tracking-no.ts for the rationale.
27
+ powrupGetTrackingNo: Boolean
28
+ powrupGetAirwayBill: Boolean
26
29
  powrupInitiateShipment: Boolean @transaction
27
30
  getLmdAwb: Boolean @transaction
28
31
  createDatShipments: Boolean @transaction
@@ -513,6 +513,7 @@ export const params = {
513
513
  'orderInventories.batchId',
514
514
  'orderInventories.warehouseCode',
515
515
  'orderInventories.refItemId',
516
+ 'orderInventories.remark',
516
517
  'remark',
517
518
  'billTo',
518
519
  'billTo.billingAddress',
@@ -12,6 +12,7 @@ import {
12
12
  AccountWebhookOrder,
13
13
  Xilnex
14
14
  } from '@things-factory/integration-accounting'
15
+ import { webhookHandler, WebhookEventsEnum } from '@things-factory/integration-base'
15
16
  // import { createPayloadLog, PayloadType } from '@things-factory/integration-base'
16
17
  import { Product, ProductBundle, ProductDetail } from '@things-factory/product-base'
17
18
  import {
@@ -545,27 +546,41 @@ xilnexRouter.post('/webhook-xilnex-transfer-note', async (context, next) => {
545
546
  })
546
547
  )
547
548
 
549
+ let result
548
550
  if (errorList.length > 0) {
549
- context.throw(404, errorList.join(', '))
551
+ // Insufficient stock: persist the order as a DraftReleaseGood so it is
552
+ // not lost. Once stock arrives, the draft can be released via the
553
+ // existing draft-to-release flow.
554
+ logger.warn(
555
+ `xilnex-router[${releaseGood.orderSourceType}][insufficient-stock]: refNo=${releaseGood.refNo} creating DraftReleaseGood; details=${errorList.join(', ')}`
556
+ )
557
+ result = await createDraftReleaseGood(
558
+ releaseGood,
559
+ massagedData.orderProducts,
560
+ customerBizplace,
561
+ warehouseDomain,
562
+ context,
563
+ tx
564
+ )
565
+ } else {
566
+ const assignedOrderProducts = await assignToInventory(
567
+ massagedData.orderProducts,
568
+ customerBizplace,
569
+ warehouseDomain,
570
+ context,
571
+ tx
572
+ )
573
+
574
+ result = await createReleaseGood(
575
+ releaseGood,
576
+ assignedOrderProducts,
577
+ customerBizplace,
578
+ warehouseDomain,
579
+ context,
580
+ tx
581
+ )
550
582
  }
551
583
 
552
- let assignedOrderProducts = await assignToInventory(
553
- massagedData.orderProducts,
554
- customerBizplace,
555
- warehouseDomain,
556
- context,
557
- tx
558
- )
559
-
560
- let result = await createReleaseGood(
561
- releaseGood,
562
- assignedOrderProducts,
563
- customerBizplace,
564
- warehouseDomain,
565
- context,
566
- tx
567
- )
568
-
569
584
  let addReleaseGood = {
570
585
  id: result.id,
571
586
  name: result.name,
@@ -889,27 +904,41 @@ xilnexRouter.post('/webhook-xilnex-stock-request', async (context, next) => {
889
904
  })
890
905
  )
891
906
 
907
+ let result
892
908
  if (errorList.length > 0) {
893
- context.throw(404, errorList.join(', '))
909
+ // Insufficient stock: persist the order as a DraftReleaseGood so it is
910
+ // not lost. Once stock arrives, the draft can be released via the
911
+ // existing draft-to-release flow.
912
+ logger.warn(
913
+ `xilnex-router[${releaseGood.orderSourceType}][insufficient-stock]: refNo=${releaseGood.refNo} creating DraftReleaseGood; details=${errorList.join(', ')}`
914
+ )
915
+ result = await createDraftReleaseGood(
916
+ releaseGood,
917
+ massagedData.orderProducts,
918
+ customerBizplace,
919
+ warehouseDomain,
920
+ context,
921
+ tx
922
+ )
923
+ } else {
924
+ const assignedOrderProducts = await assignToInventory(
925
+ massagedData.orderProducts,
926
+ customerBizplace,
927
+ warehouseDomain,
928
+ context,
929
+ tx
930
+ )
931
+
932
+ result = await createReleaseGood(
933
+ releaseGood,
934
+ assignedOrderProducts,
935
+ customerBizplace,
936
+ warehouseDomain,
937
+ context,
938
+ tx
939
+ )
894
940
  }
895
941
 
896
- let assignedOrderProducts = await assignToInventory(
897
- massagedData.orderProducts,
898
- customerBizplace,
899
- warehouseDomain,
900
- context,
901
- tx
902
- )
903
-
904
- let result = await createReleaseGood(
905
- releaseGood,
906
- assignedOrderProducts,
907
- customerBizplace,
908
- warehouseDomain,
909
- context,
910
- tx
911
- )
912
-
913
942
  let addReleaseGood = {
914
943
  id: result.id,
915
944
  name: result.name,
@@ -1072,12 +1101,21 @@ xilnexRouter.post('/webhook-xilnex-stock-request', async (context, next) => {
1072
1101
  })
1073
1102
 
1074
1103
  async function checkExistingOrder(releaseGood, bizplace, checkDuplicationValidation = true, context, tx) {
1075
- const { domain }: { domain: Domain } = context.state
1104
+ // Use the warehouse domain set on the releaseGood (caller passes warehouseDomain explicitly)
1105
+ // so dedupe is consistent with the domain that ReleaseGood / DraftReleaseGood are saved under.
1106
+ const domain: Domain = releaseGood.domain
1076
1107
  const refNo: string = releaseGood.refNo
1077
1108
  const refNo2: string = releaseGood.refNo2
1078
1109
  const refNo3: string = releaseGood.refNo3
1079
1110
 
1080
1111
  if (checkDuplicationValidation) {
1112
+ // Acquire a transaction-scoped advisory lock keyed on the dedupe tuple. Serializes
1113
+ // concurrent webhook deliveries for the same (domain, refNo, refNo2, refNo3) so the
1114
+ // SELECT-then-INSERT pattern below can't race two duplicates through. The lock is
1115
+ // automatically released when the transaction commits or rolls back.
1116
+ const lockKey = `xilnex-dedupe:${domain.id}:${refNo || ''}:${refNo2 || ''}:${refNo3 || ''}`
1117
+ await tx.query(`SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, [lockKey])
1118
+
1081
1119
  const foundDraftReleaseGood: DraftReleaseGood = await tx.getRepository(DraftReleaseGood).findOne({
1082
1120
  where: {
1083
1121
  domain,
@@ -1770,3 +1808,93 @@ async function createReleaseGood(
1770
1808
  throw error
1771
1809
  }
1772
1810
  }
1811
+
1812
+ // Inline draft creation that mirrors createReleaseGood's domain handling: takes warehouseDomain
1813
+ // explicitly instead of relying on context.state.domain. A simplified parallel of
1814
+ // createDraftReleaseGoodFunction in @things-factory/sales-base — kept tight (no LMD / shipping
1815
+ // order / attachments / country normalization) because none apply to the Xilnex webhook path.
1816
+ async function createDraftReleaseGood(
1817
+ releaseGood: any,
1818
+ orderProducts: OrderProduct[],
1819
+ bizplace: Bizplace,
1820
+ domain: Domain,
1821
+ context: any,
1822
+ tx: EntityManager
1823
+ ): Promise<DraftReleaseGood> {
1824
+ const { user }: { user: any } = context.state
1825
+
1826
+ if (orderProducts.length <= 0) {
1827
+ throw new ValidationError({
1828
+ errorCode: 'ITEM_NOT_FOUND',
1829
+ message: 'no order items found',
1830
+ detail: JSON.stringify(releaseGood)
1831
+ })
1832
+ }
1833
+
1834
+ // Resolve DRO number under the warehouse domain
1835
+ const droNoSetting: Setting = await tx.getRepository(Setting).findOne({
1836
+ where: { domain, name: ORDER_NUMBER_SETTING_KEY.DRO_NUMBER_RULE }
1837
+ })
1838
+
1839
+ const orderNo: string = droNoSetting
1840
+ ? await generateId({ domain, type: ORDER_NUMBER_RULE_TYPE.DRO_NUMBER, seed: {} })
1841
+ : OrderNoGenerator.draftReleaseGood()
1842
+
1843
+ const newDraftReleaseGood = {
1844
+ name: orderNo,
1845
+ domain,
1846
+ bizplace,
1847
+ refNo: releaseGood.refNo,
1848
+ refNo2: releaseGood.refNo2 || null,
1849
+ refNo3: releaseGood.refNo3 || null,
1850
+ collectionOrderNo: releaseGood.collectionOrderNo,
1851
+ type: releaseGood?.type ? releaseGood.type : 'b2b',
1852
+ courierOption: releaseGood.courierOption,
1853
+ exportOption: releaseGood.exportOption,
1854
+ ownTransport: releaseGood.ownTransport,
1855
+ packingOption: releaseGood.packingOption,
1856
+ marketplaceOrderStatus: releaseGood?.marketplaceOrderStatus || null,
1857
+ releaseDate: releaseGood.releaseDate,
1858
+ remark: releaseGood?.remark || null,
1859
+ source: releaseGood?.source,
1860
+ status: DRAFT_RELEASE_ORDER_STATUS.DRAFT,
1861
+ creator: user,
1862
+ updater: user
1863
+ }
1864
+
1865
+ const resultDraftReleaseGood: DraftReleaseGood = await tx.getRepository(DraftReleaseGood).save(newDraftReleaseGood)
1866
+
1867
+ // Persist OrderProduct rows linked to the draft. Mirrors createReleaseGood's
1868
+ // OrderProduct save loop, swapping releaseGood for draftReleaseGood and skipping
1869
+ // OrderInventory creation (drafts have no assigned inventory yet).
1870
+ const savedOrderProducts = await Promise.all(
1871
+ orderProducts.map(async orderProduct => {
1872
+ // status MUST be 'DRAFT' — draft-release-good-create.ts:130 filters
1873
+ // orderProducts on `op.status == 'DRAFT'` during draft → release conversion.
1874
+ // Setting any other ORDER_PRODUCT_STATUS value here would cause the converted
1875
+ // ReleaseGood to come out with zero line items.
1876
+ const draftOrderProduct = Object.assign(new OrderProduct(), {
1877
+ ...orderProduct,
1878
+ name: OrderNoGenerator.orderProduct(),
1879
+ domain,
1880
+ bizplace,
1881
+ draftReleaseGood: resultDraftReleaseGood,
1882
+ type: ORDER_TYPES.RELEASE_OF_GOODS,
1883
+ status: DRAFT_RELEASE_ORDER_STATUS.DRAFT,
1884
+ creator: user,
1885
+ updater: user
1886
+ })
1887
+ return await tx.getRepository(OrderProduct).save(draftOrderProduct)
1888
+ })
1889
+ )
1890
+
1891
+ resultDraftReleaseGood.orderProducts = savedOrderProducts
1892
+
1893
+ // Notify external subscribers that a draft has been created. Fire-and-forget,
1894
+ // matching the pattern used at add-release-order.ts:557 for ReleaseOrderCreated.
1895
+ // Note: this fires inside the outer transaction; if the transaction rolls back
1896
+ // the webhook will still have been queued. Consumers should be idempotent.
1897
+ webhookHandler(resultDraftReleaseGood, bizplace, WebhookEventsEnum.DraftOrderCreated)
1898
+
1899
+ return resultDraftReleaseGood
1900
+ }