@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.
- package/dist-server/graphql/resolvers/email/send-emails.js +5 -1
- package/dist-server/graphql/resolvers/email/send-emails.js.map +1 -1
- package/dist-server/graphql/resolvers/synchronization/powrup-get-airway-bill.js +103 -53
- package/dist-server/graphql/resolvers/synchronization/powrup-get-airway-bill.js.map +1 -1
- package/dist-server/graphql/resolvers/synchronization/powrup-get-tracking-no.js +80 -39
- package/dist-server/graphql/resolvers/synchronization/powrup-get-tracking-no.js.map +1 -1
- package/dist-server/graphql/types/synchronization/index.js +5 -2
- package/dist-server/graphql/types/synchronization/index.js.map +1 -1
- package/dist-server/routers/api/restful-apis/v1/utils/params.js +1 -0
- package/dist-server/routers/api/restful-apis/v1/utils/params.js.map +1 -1
- package/dist-server/routers/xilnex-router.js +92 -7
- package/dist-server/routers/xilnex-router.js.map +1 -1
- package/package.json +20 -20
- package/server/graphql/resolvers/email/send-emails.ts +5 -1
- package/server/graphql/resolvers/synchronization/powrup-get-airway-bill.ts +119 -66
- package/server/graphql/resolvers/synchronization/powrup-get-tracking-no.ts +81 -41
- package/server/graphql/types/synchronization/index.ts +5 -2
- package/server/routers/api/restful-apis/v1/utils/params.ts +1 -0
- package/server/routers/xilnex-router.ts +165 -37
|
@@ -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
|
|
14
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
42
|
+
try {
|
|
43
|
+
const manager = queryRunner.manager
|
|
38
44
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
35
|
+
const powrup: Powrup = await manager.findOne(Powrup, {
|
|
36
|
+
where: { platform: Platform.POWRUP, active: true }
|
|
37
|
+
})
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|