@things-factory/operato-hub 4.3.822 → 4.3.824

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.
Files changed (29) hide show
  1. package/config.development.js +89 -45
  2. package/dist-server/graphql/resolvers/email/send-emails.js +5 -1
  3. package/dist-server/graphql/resolvers/email/send-emails.js.map +1 -1
  4. package/dist-server/graphql/resolvers/synchronization/powrup-get-airway-bill.js +103 -53
  5. package/dist-server/graphql/resolvers/synchronization/powrup-get-airway-bill.js.map +1 -1
  6. package/dist-server/graphql/resolvers/synchronization/powrup-get-tracking-no.js +80 -39
  7. package/dist-server/graphql/resolvers/synchronization/powrup-get-tracking-no.js.map +1 -1
  8. package/dist-server/graphql/resolvers/synchronization/powrup-initiate-shipment.js +95 -65
  9. package/dist-server/graphql/resolvers/synchronization/powrup-initiate-shipment.js.map +1 -1
  10. package/dist-server/graphql/resolvers/synchronization/sellercraft-initiate-shipment.js +109 -74
  11. package/dist-server/graphql/resolvers/synchronization/sellercraft-initiate-shipment.js.map +1 -1
  12. package/dist-server/graphql/types/synchronization/index.js +7 -4
  13. package/dist-server/graphql/types/synchronization/index.js.map +1 -1
  14. package/dist-server/routers/api/restful-apis/v1/utils/params.js +1 -0
  15. package/dist-server/routers/api/restful-apis/v1/utils/params.js.map +1 -1
  16. package/dist-server/routers/api/restful-apis/v1/warehouse/add-release-order.js +3 -2
  17. package/dist-server/routers/api/restful-apis/v1/warehouse/add-release-order.js.map +1 -1
  18. package/dist-server/routers/xilnex-router.js +92 -7
  19. package/dist-server/routers/xilnex-router.js.map +1 -1
  20. package/package.json +20 -20
  21. package/server/graphql/resolvers/email/send-emails.ts +5 -1
  22. package/server/graphql/resolvers/synchronization/powrup-get-airway-bill.ts +119 -66
  23. package/server/graphql/resolvers/synchronization/powrup-get-tracking-no.ts +81 -41
  24. package/server/graphql/resolvers/synchronization/powrup-initiate-shipment.ts +117 -79
  25. package/server/graphql/resolvers/synchronization/sellercraft-initiate-shipment.ts +132 -89
  26. package/server/graphql/types/synchronization/index.ts +7 -4
  27. package/server/routers/api/restful-apis/v1/utils/params.ts +1 -0
  28. package/server/routers/api/restful-apis/v1/warehouse/add-release-order.ts +14 -2
  29. package/server/routers/xilnex-router.ts +165 -37
@@ -1,116 +1,159 @@
1
- import { getConnection, getRepository } from 'typeorm'
1
+ import { getConnection } from 'typeorm'
2
2
 
3
3
  import { ApplicationType } from '@things-factory/auth-base'
4
4
  import { logger } from '@things-factory/env'
5
5
  import { Sellercraft, SellercraftAPI } from '@things-factory/integration-sellercraft'
6
6
  import { ORDER_STATUS, OrderPackage } from '@things-factory/sales-base'
7
7
 
8
+ const ADVISORY_LOCK_KEY = 'sellercraft-initiate-shipment'
9
+
8
10
  export const sellercraftInitiateShipmentResolver = {
9
11
  async sellercraftInitiateShipment(_: void, {}, context: any): Promise<void> {
10
12
  const maxRTSRetry: number = 10
13
+
14
+ // Same fix as powrupGetAirwayBill / powrupGetTrackingNo: do not wrap the
15
+ // resolver in a single long transaction across external SellercraftAPI HTTP
16
+ // calls. The previous version held row locks on every UPDATE'd order_packages
17
+ // row until the loop finished, which was the direct cause of the order_packages
18
+ // lock chains observed in pg_stat_activity (sessions stuck `idle in transaction`
19
+ // for 10+ minutes after `UPDATE order_packages SET market_rts_call_success`).
20
+ //
21
+ // Use a single QueryRunner with a session-level advisory lock for concurrency
22
+ // safety, and let each UPDATE auto-commit on its own (row lock held only for
23
+ // the duration of that single statement).
24
+ const queryRunner = getConnection().createQueryRunner()
25
+ let connected = false
26
+
11
27
  try {
12
- await getConnection().transaction(async tx => {
13
- try {
14
- let orderPackageQb = tx
15
- .getRepository(OrderPackage)
16
- .createQueryBuilder('opk')
17
- .innerJoinAndSelect('opk.releaseGood', 'rg')
18
- .innerJoinAndSelect('opk.domain', 'domain')
19
- .innerJoinAndSelect('opk.bizplace', 'bizplace')
20
- .innerJoinAndSelect('bizplace.domain', 'bdomain')
21
- .leftJoinAndSelect('opk.creator', 'creator')
22
- .leftJoinAndSelect('rg.lastMileDelivery', 'lmd')
23
- .where(`rg.source = :source`, { source: ApplicationType.SELLERCRAFT })
24
- .andWhere(`rg.type = :rgType`, { rgType: 'b2c' })
25
- .andWhere(`rg.status = :status`, {
26
- status: ORDER_STATUS.DONE
28
+ await queryRunner.connect()
29
+ connected = true
30
+
31
+ const lockResult: { acquired: boolean }[] = await queryRunner.query(
32
+ `SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS acquired`,
33
+ [ADVISORY_LOCK_KEY]
34
+ )
35
+ if (!lockResult?.[0]?.acquired) {
36
+ logger.info(`sellercraftInitiateShipment: another instance is running, skipping`)
37
+ return
38
+ }
39
+
40
+ try {
41
+ const manager = queryRunner.manager
42
+
43
+ const orderPackagesWithoutRTS: OrderPackage[] = await manager
44
+ .createQueryBuilder(OrderPackage, 'opk')
45
+ .innerJoinAndSelect('opk.releaseGood', 'rg')
46
+ .innerJoinAndSelect('opk.domain', 'domain')
47
+ .innerJoinAndSelect('opk.bizplace', 'bizplace')
48
+ .innerJoinAndSelect('bizplace.domain', 'bdomain')
49
+ .leftJoinAndSelect('opk.creator', 'creator')
50
+ .leftJoinAndSelect('rg.lastMileDelivery', 'lmd')
51
+ .where(`rg.source = :source`, { source: ApplicationType.SELLERCRAFT })
52
+ .andWhere(`rg.type = :rgType`, { rgType: 'b2c' })
53
+ .andWhere(`rg.status = :status`, { status: ORDER_STATUS.DONE })
54
+ .andWhere(`opk.market_rts_call_success ISNULL`)
55
+ .andWhere(`COALESCE(opk.market_rts_call_retry, 0) <= :maxRTSRetry`, { maxRTSRetry })
56
+ .orderBy(`opk.created_at`)
57
+ .getMany()
58
+
59
+ if (orderPackagesWithoutRTS?.length > 0) {
60
+ for (let i = 0; i < orderPackagesWithoutRTS.length; i++) {
61
+ const orderPackage: OrderPackage = orderPackagesWithoutRTS[i]
62
+ const releaseGood = orderPackage.releaseGood
63
+
64
+ const sellercraft: Sellercraft = await manager.findOne(Sellercraft, {
65
+ where: { domain: { id: orderPackage.bizplace.domain.id }, status: 'ACTIVE' }
27
66
  })
28
- .andWhere(`opk.market_rts_call_success ISNULL`)
29
- .andWhere(`COALESCE(opk.market_rts_call_retry, 0) <= :maxRTSRetry`, { maxRTSRetry: maxRTSRetry })
30
- .orderBy(`opk.created_at`)
31
67
 
32
- let orderPackagesWithoutRTS: OrderPackage[] = await orderPackageQb.getMany()
68
+ if (!sellercraft) {
69
+ continue
70
+ }
33
71
 
34
- if (orderPackagesWithoutRTS?.length > 0) {
35
- for (let i = 0; i < orderPackagesWithoutRTS.length; i++) {
36
- const orderPackage: OrderPackage = orderPackagesWithoutRTS[i]
37
- const releaseGood = orderPackage.releaseGood
72
+ let sellercraftSetting: any = sellercraft.sellercraftSetting
73
+ let disableInitiateOrderShipment: boolean =
74
+ sellercraftSetting?.disableInitiateOrderShipment &&
75
+ sellercraftSetting?.disableInitiateOrderShipment == 1
76
+ ? true
77
+ : false
38
78
 
39
- const sellercraft: Sellercraft = await tx
40
- .getRepository(Sellercraft)
41
- .findOne({ where: { domain: { id: orderPackage.bizplace.domain.id }, status: 'ACTIVE' } })
79
+ if (
80
+ disableInitiateOrderShipment === true ||
81
+ (releaseGood.lmdOption == true && orderPackage?.trackingNo == null)
82
+ ) {
83
+ continue
84
+ }
42
85
 
43
- if (!sellercraft) {
44
- continue
86
+ let orderInformation: any = {
87
+ accountId: sellercraft.accountId,
88
+ orderId: releaseGood.refNo2,
89
+ packageId: orderPackage.packageId
90
+ }
91
+ if (orderPackage?.releaseGood?.lmdOption) {
92
+ orderInformation = {
93
+ ...orderInformation,
94
+ awb: orderPackage.airwayBill,
95
+ trackingNo: orderPackage.trackingNo,
96
+ lmdOption: orderPackage?.releaseGood?.lmdOption,
97
+ shipperName: orderPackage?.releaseGood?.lastMileDelivery?.platform
45
98
  }
99
+ }
46
100
 
47
- let sellercraftSetting: any = sellercraft.sellercraftSetting
48
- let disableInitiateOrderShipment: boolean =
49
- sellercraftSetting?.disableInitiateOrderShipment &&
50
- sellercraftSetting?.disableInitiateOrderShipment == 1
51
- ? true
52
- : false
53
-
54
- if (
55
- disableInitiateOrderShipment === true ||
56
- (releaseGood.lmdOption == true && orderPackage?.trackingNo == null)
57
- ) {
58
- continue
59
- }
101
+ performance.mark(`start:${orderPackage.releaseGood.refNo2} - ${orderPackage.packageId}`)
102
+ try {
103
+ await SellercraftAPI.initiateOrderShipment(sellercraft, {
104
+ ...orderInformation,
105
+ context: { state: { domain: null, user: null } }
106
+ })
60
107
 
61
- let orderInformation: any = {
62
- accountId: sellercraft.accountId,
63
- orderId: releaseGood.refNo2,
64
- packageId: orderPackage.packageId
65
- }
66
- if (orderPackage?.releaseGood?.lmdOption) {
67
- orderInformation = {
68
- ...orderInformation,
69
- awb: orderPackage.airwayBill,
70
- trackingNo: orderPackage.trackingNo,
71
- lmdOption: orderPackage?.releaseGood?.lmdOption,
72
- shipperName: orderPackage?.releaseGood?.lastMileDelivery?.platform
73
- }
74
- }
108
+ // Auto-commit single-statement UPDATE — row lock held only for this UPDATE,
109
+ // not across the API call above.
110
+ await manager.update(OrderPackage, { id: orderPackage.id }, { marketRtsCallSuccess: new Date() })
75
111
 
76
- performance.mark(`start:${orderPackage.releaseGood.refNo2} - ${orderPackage.packageId}`)
77
- try {
78
- await SellercraftAPI.initiateOrderShipment(sellercraft, {
79
- ...orderInformation,
80
- context: { state: { domain: null, user: null } }
81
- })
82
- await tx
83
- .getRepository(OrderPackage)
84
- .update({ id: orderPackage.id }, { marketRtsCallSuccess: new Date() })
85
- performance.mark(`end:${orderPackage.releaseGood.refNo2} - ${orderPackage.packageId}`)
86
- } catch (error) {
87
- performance.mark(`end:${orderPackage.releaseGood.refNo2} - ${orderPackage.packageId}`)
88
- logger.error(
89
- `sellercraftInitiateShipment[InitiateShipment][Order Package ID: ${orderPackage.id}]: ${error}`
90
- )
91
- await getRepository(OrderPackage)
92
- .createQueryBuilder()
93
- .update(OrderPackage)
94
- .set({ marketRtsCallRetry: () => `coalesce("market_rts_call_retry", 0) + 1` })
95
- .where('id = :id', { id: orderPackage.id })
96
- .execute()
97
- continue
98
- } finally {
99
- let perf = performance.measure(
100
- `sellercraftInitiateShipment: ${orderPackage.releaseGood.refNo2} - ${orderPackage.packageId}`,
101
- `start:${orderPackage.releaseGood.refNo2} - ${orderPackage.packageId}`,
102
- `end:${orderPackage.releaseGood.refNo2} - ${orderPackage.packageId}`
103
- )
104
- console.info(perf.toJSON())
105
- }
112
+ performance.mark(`end:${orderPackage.releaseGood.refNo2} - ${orderPackage.packageId}`)
113
+ } catch (error) {
114
+ performance.mark(`end:${orderPackage.releaseGood.refNo2} - ${orderPackage.packageId}`)
115
+ logger.error(
116
+ `sellercraftInitiateShipment[InitiateShipment][Order Package ID: ${orderPackage.id}]: ${error}`
117
+ )
118
+
119
+ await manager
120
+ .createQueryBuilder()
121
+ .update(OrderPackage)
122
+ .set({ marketRtsCallRetry: () => `coalesce("market_rts_call_retry", 0) + 1` })
123
+ .where('id = :id', { id: orderPackage.id })
124
+ .execute()
125
+
126
+ continue
127
+ } finally {
128
+ let perf = performance.measure(
129
+ `sellercraftInitiateShipment: ${orderPackage.releaseGood.refNo2} - ${orderPackage.packageId}`,
130
+ `start:${orderPackage.releaseGood.refNo2} - ${orderPackage.packageId}`,
131
+ `end:${orderPackage.releaseGood.refNo2} - ${orderPackage.packageId}`
132
+ )
133
+ console.info(perf.toJSON())
106
134
  }
107
135
  }
108
- } catch (e) {
109
- logger.error(`sellercraftInitiateShipment[InitiateShipment]: ${e}`)
110
136
  }
111
- })
137
+ } catch (e) {
138
+ logger.error(`sellercraftInitiateShipment[InitiateShipment]: ${e}`)
139
+ } finally {
140
+ try {
141
+ await queryRunner.query(`SELECT pg_advisory_unlock(hashtextextended($1, 0))`, [ADVISORY_LOCK_KEY])
142
+ } catch (releaseErr) {
143
+ logger.error(`sellercraftInitiateShipment[AdvisoryUnlock]: ${releaseErr}`)
144
+ }
145
+ }
112
146
  } catch (e) {
113
147
  logger.error(`sellercraftInitiateShipment[MainError]: ${e}`)
148
+ } finally {
149
+ if (connected) {
150
+ try {
151
+ await queryRunner.query(`SELECT pg_advisory_unlock_all()`)
152
+ } catch (unlockAllErr) {
153
+ logger.error(`sellercraftInitiateShipment[AdvisoryUnlockAll]: ${unlockAllErr}`)
154
+ }
155
+ }
156
+ await queryRunner.release()
114
157
  }
115
158
  }
116
159
  }
@@ -19,11 +19,14 @@ export const Mutation = /* GraphQL */ `
19
19
  syncAllProductsToSellercraft: Boolean @transaction
20
20
  syncAllProductsToPowrup: Boolean @transaction
21
21
  sellercraftPackMarketplaceOrder: Boolean @transaction
22
- sellercraftInitiateShipment: Boolean @transaction
23
22
  powrupPackOrder: Boolean @transaction
24
- powrupGetTrackingNo: Boolean @transaction
25
- powrupGetAirwayBill: Boolean @transaction
26
- powrupInitiateShipment: Boolean @transaction
23
+ # Intentionally NOT @transaction — these resolvers manage their own QueryRunner
24
+ # to avoid holding row locks on order_packages across external API calls.
25
+ # See the resolver files for the rationale.
26
+ powrupGetTrackingNo: Boolean
27
+ powrupGetAirwayBill: Boolean
28
+ powrupInitiateShipment: Boolean
29
+ sellercraftInitiateShipment: Boolean
27
30
  getLmdAwb: Boolean @transaction
28
31
  createDatShipments: Boolean @transaction
29
32
  autoUpdateAllMarketplaceProductVariationQuantity(companyDomainId: String, warehouseId: String): 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',
@@ -929,7 +929,18 @@ async function massageOrderItems(
929
929
  inputOrderProducts = await inputOrderProducts.reduce(async (accPromise, itm, idx) => {
930
930
  const acc = await accPromise
931
931
  let { sku, refCode } = itm.product
932
- let { packingType, packingSize, batchId, releaseQty, sellingPrice, paidAmount, uom, refItemId, warehouseCode } = itm
932
+ let {
933
+ packingType,
934
+ packingSize,
935
+ batchId,
936
+ releaseQty,
937
+ sellingPrice,
938
+ paidAmount,
939
+ uom,
940
+ refItemId,
941
+ warehouseCode,
942
+ remark
943
+ } = itm
933
944
 
934
945
  if (!sku && !refCode) {
935
946
  throw new ApiError('E01', 'sku or refCode not found')
@@ -965,7 +976,8 @@ async function massageOrderItems(
965
976
  uom: uom,
966
977
  batchId: batchId?.trim() || '',
967
978
  refItemId: refItemId ?? '',
968
- warehouseCode: normalizedWarehouseCode
979
+ warehouseCode: normalizedWarehouseCode,
980
+ remark: remark || null
969
981
  })
970
982
  }
971
983
 
@@ -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
+ }