@things-factory/operato-hub 4.3.701 → 4.3.705

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.
@@ -26,11 +26,15 @@ router.post(
26
26
  // check if phone or email already belongs in bizplace
27
27
  const phones = []
28
28
  const emails = []
29
+ const ids = []
29
30
  let values = []
30
31
  for (const datapoint of context.request.body.data) {
31
32
  phones.push(datapoint.phone)
32
33
  emails.push(datapoint.email)
33
- const value = {
34
+ if (datapoint.id) {
35
+ ids.push(datapoint.id)
36
+ }
37
+ const value: any = {
34
38
  name: datapoint.name,
35
39
  description: datapoint.description,
36
40
  companyName: datapoint.companyName,
@@ -43,6 +47,8 @@ router.post(
43
47
  address2: datapoint.address2,
44
48
  city: datapoint.city,
45
49
  state: datapoint.state,
50
+ country: datapoint.country,
51
+ releaseShelfLife: datapoint.releaseShelfLife,
46
52
  postCode: datapoint.postCode,
47
53
  type: datapoint.type,
48
54
  domain: context.state.domain,
@@ -50,9 +56,28 @@ router.post(
50
56
  creator: context.state.user,
51
57
  updater: context.state.user
52
58
  }
59
+ // Add id if provided by external system
60
+ if (datapoint.id) {
61
+ value.id = datapoint.id
62
+ }
53
63
  values.push(value)
54
64
  }
55
65
 
66
+ // Check for duplicate IDs if provided
67
+ if (ids.length > 0) {
68
+ const checkDuplicateIds: ContactPoint[] = await tx
69
+ .getRepository(ContactPoint)
70
+ .createQueryBuilder('cp')
71
+ .where('cp.id IN (:...ids)', { ids })
72
+ .getMany()
73
+ if (checkDuplicateIds.length > 0) {
74
+ throw new ApiError(
75
+ 'E05',
76
+ `Contact point ID(s) already exist: ${checkDuplicateIds.map(cp => cp.id).join(', ')}`
77
+ )
78
+ }
79
+ }
80
+
56
81
  const checkDuplicateEmailPhone: ContactPoint = await tx
57
82
  .getRepository(ContactPoint)
58
83
  .createQueryBuilder('cp')
@@ -80,6 +105,7 @@ router.post(
80
105
  let data = []
81
106
  for (const contactPoint of result.generatedMaps) {
82
107
  const datapoint = {
108
+ id: contactPoint.id,
83
109
  name: contactPoint.name,
84
110
  description: contactPoint.description,
85
111
  companyName: contactPoint.companyName,
@@ -92,6 +118,8 @@ router.post(
92
118
  address2: contactPoint.address2,
93
119
  city: contactPoint.city,
94
120
  state: contactPoint.state,
121
+ country: contactPoint.country,
122
+ releaseShelfLife: contactPoint.releaseShelfLife,
95
123
  postCode: contactPoint.postCode,
96
124
  type: contactPoint.type,
97
125
  createdAt: contactPoint.createdAt
@@ -83,6 +83,7 @@ export const params = {
83
83
  'get-webhook-details': ['warehouseBizplaceId', 'webhookId'],
84
84
  'add-contact-points': [
85
85
  'bizplaceId',
86
+ 'id',
86
87
  'name',
87
88
  'companyName',
88
89
  'description',
@@ -95,6 +96,8 @@ export const params = {
95
96
  'address2',
96
97
  'city',
97
98
  'state',
99
+ 'country',
100
+ 'releaseShelfLife',
98
101
  'postCode',
99
102
  'type'
100
103
  ],
@@ -358,6 +361,7 @@ export const params = {
358
361
  'refNo2',
359
362
  'refNo3',
360
363
  'refOrderId',
364
+ 'contactPointId',
361
365
  'type',
362
366
  'transporter',
363
367
  'trackingNo',
@@ -1112,6 +1116,11 @@ export const reqParams = {
1112
1116
  required: true,
1113
1117
  type: 'field'
1114
1118
  },
1119
+ {
1120
+ name: 'contactPointId',
1121
+ required: false,
1122
+ type: 'field'
1123
+ },
1115
1124
  {
1116
1125
  name: 'shippingOrder',
1117
1126
  required: false,
@@ -1201,7 +1210,7 @@ export const reqParams = {
1201
1210
  name: 'releaseGood',
1202
1211
  required: true,
1203
1212
  type: 'object',
1204
- data: [{ name: 'roId', required: true, type: 'field' }]
1213
+ data: []
1205
1214
  },
1206
1215
  {
1207
1216
  name: 'shippingOrder',
@@ -13,7 +13,7 @@ import { v4 as uuidv4 } from 'uuid'
13
13
  import { webhookHandler, WebhookEventsEnum } from '@things-factory/integration-base'
14
14
  import { restfulApiRouter as router } from '@things-factory/api'
15
15
  import { User } from '@things-factory/auth-base'
16
- import { Bizplace, getCompanyBizplace } from '@things-factory/biz-base'
16
+ import { Bizplace, ContactPoint, getCompanyBizplace } from '@things-factory/biz-base'
17
17
  import { GeoCountry } from '@things-factory/geography'
18
18
  import { generateId } from '@things-factory/id-rule-base'
19
19
  import { LastMileDelivery } from '@things-factory/integration-lmd'
@@ -87,6 +87,23 @@ router.post(
87
87
  relations: ['domain']
88
88
  })
89
89
 
90
+ // optional override: release shelf life from ContactPoint
91
+ const contactPointId: string | undefined = bodyReq?.contactPointId
92
+ let releaseShelfLifeOverride: number | null = null
93
+ let deliverToContactPoint: ContactPoint | null = null
94
+ if (contactPointId) {
95
+ const contactPoint: ContactPoint | undefined = await tx
96
+ .getRepository(ContactPoint)
97
+ .findOne({ where: { id: contactPointId } })
98
+ if (!contactPoint) {
99
+ throw new ApiError('E04', 'contactPoint not found')
100
+ }
101
+ if (contactPoint?.releaseShelfLife != null && contactPoint.releaseShelfLife !== 0) {
102
+ releaseShelfLifeOverride = contactPoint.releaseShelfLife
103
+ }
104
+ deliverToContactPoint = contactPoint
105
+ }
106
+
90
107
  const worksheetPickingAssignment: Setting = await tx.getRepository(Setting).findOne({
91
108
  where: { domain, category: 'id-rule', name: 'enable-worksheet-picking-activation-assignment' }
92
109
  })
@@ -105,7 +122,12 @@ router.post(
105
122
  })
106
123
  }
107
124
 
108
- let newPhone1 = bodyReq?.deliverTo?.phone1 ? bodyReq.deliverTo.phone1.replace(/\D/g, '') : null
125
+ // derive phone values: prefer contact point when provided, else use body
126
+ let newPhone1 = deliverToContactPoint?.phone
127
+ ? String(deliverToContactPoint.phone).replace(/\D/g, '')
128
+ : bodyReq?.deliverTo?.phone1
129
+ ? bodyReq.deliverTo.phone1.replace(/\D/g, '')
130
+ : null
109
131
  let newPhone2 = bodyReq?.deliverTo?.phone2 ? bodyReq.deliverTo.phone2.replace(/\D/g, '') : null
110
132
 
111
133
  releaseGood = {
@@ -126,23 +148,36 @@ router.post(
126
148
  type: bodyReq.type,
127
149
  marketplaceOrderStatus: bodyReq?.marketplaceOrderStatus,
128
150
  remark: bodyReq?.remark || null,
129
- billingAddress: bodyReq?.billTo?.billingAddress || null,
130
- deliveryAddress1: bodyReq?.deliverTo?.deliveryAddress1 || null,
131
- deliveryAddress2: bodyReq?.deliverTo?.deliveryAddress2 || null,
151
+ // when contactPoint exists, override target fields from contactPoint, else use request body
152
+ billingAddress: deliverToContactPoint
153
+ ? deliverToContactPoint.billingAddress || null
154
+ : bodyReq?.billTo?.billingAddress || null,
155
+ deliveryAddress1: deliverToContactPoint
156
+ ? deliverToContactPoint.address || null
157
+ : bodyReq?.deliverTo?.deliveryAddress1 || null,
158
+ deliveryAddress2: deliverToContactPoint
159
+ ? deliverToContactPoint.address2 || null
160
+ : bodyReq?.deliverTo?.deliveryAddress2 || null,
132
161
  deliveryAddress3: bodyReq?.deliverTo?.deliveryAddress3 || null,
133
162
  deliveryAddress4: bodyReq?.deliverTo?.deliveryAddress4 || null,
134
163
  deliveryAddress5: bodyReq?.deliverTo?.deliveryAddress5 || null,
135
- attentionTo: bodyReq?.deliverTo?.attentionTo || null,
136
- attentionCompany: bodyReq?.deliverTo?.attentionCompany || null,
137
- city: bodyReq?.deliverTo?.city || null,
164
+ attentionTo: deliverToContactPoint
165
+ ? deliverToContactPoint.name || null
166
+ : bodyReq?.deliverTo?.attentionTo || null,
167
+ attentionCompany: deliverToContactPoint
168
+ ? deliverToContactPoint.companyName || null
169
+ : bodyReq?.deliverTo?.attentionCompany || null,
170
+ city: deliverToContactPoint ? deliverToContactPoint.city || null : bodyReq?.deliverTo?.city || null,
138
171
  ward: bodyReq?.deliverTo?.ward,
139
- district: bodyReq?.deliverTo?.district,
140
- state: bodyReq?.deliverTo?.state || null,
141
- postalCode: bodyReq?.deliverTo?.postalCode || null,
142
- country: bodyReq?.deliverTo?.country || null,
172
+ district: deliverToContactPoint ? null : bodyReq?.deliverTo?.district,
173
+ state: deliverToContactPoint ? deliverToContactPoint.state || null : bodyReq?.deliverTo?.state || null,
174
+ postalCode: deliverToContactPoint
175
+ ? deliverToContactPoint.postCode || null
176
+ : bodyReq?.deliverTo?.postalCode || null,
177
+ country: deliverToContactPoint ? null : bodyReq?.deliverTo?.country || null,
143
178
  phone1: newPhone1,
144
179
  phone2: newPhone2,
145
- email: bodyReq?.deliverTo?.email || null,
180
+ email: deliverToContactPoint ? deliverToContactPoint.email || null : bodyReq?.deliverTo?.email || null,
146
181
  transporter: bodyReq?.transporter,
147
182
  trackingNo: bodyReq?.trackingNo,
148
183
  airwayBill: bodyReq?.airwayBill,
@@ -162,6 +197,7 @@ router.post(
162
197
  shippingFee: bodyReq?.shippingFee,
163
198
  lmdOption: bodyReq?.lmdOption,
164
199
  priorityDelivery: bodyReq?.priorityDelivery,
200
+ deliverTo: deliverToContactPoint || null,
165
201
  shippingOrder: bodyReq?.shippingOrder
166
202
  ? {
167
203
  shipName: bodyReq?.shippingOrder.shipName,
@@ -235,150 +271,156 @@ router.post(
235
271
 
236
272
  const invWithoutBatchId = batchIdStates.withoutBatchId
237
273
 
274
+ // Helper function to build inventory availability query
275
+ const buildInventoryAvailabilityQuery = (
276
+ item: any,
277
+ domain: Domain,
278
+ customerBizplace: Bizplace,
279
+ options: {
280
+ batchId?: string | null
281
+ warehouseName?: string | null
282
+ releaseShelfLifeOverride: number | null
283
+ requireWarehouseJoin?: boolean
284
+ includeLockInventoryCheck?: boolean
285
+ }
286
+ ) => {
287
+ const { batchId, warehouseName, releaseShelfLifeOverride, requireWarehouseJoin, includeLockInventoryCheck } =
288
+ options
289
+
290
+ const hasWarehouse = warehouseName != null
291
+ const hasBatch = batchId != null
292
+
293
+ // Determine warehouse join strategy
294
+ const warehouseJoin =
295
+ requireWarehouseJoin || hasWarehouse ? 'inner join warehouses w on w.id = loc.warehouse_id' : ''
296
+
297
+ // Build warehouse filter
298
+ let warehouseFilter = ''
299
+ if (requireWarehouseJoin) {
300
+ // For batch queries: always join warehouses, filter optionally
301
+ warehouseFilter = 'and ($8::text is null or w.name = $8::text)'
302
+ } else if (hasWarehouse) {
303
+ // For non-batch queries: only filter if warehouse is specified
304
+ warehouseFilter = 'and w.name = $7'
305
+ }
306
+
307
+ // Build batch filter
308
+ const batchFilter = hasBatch ? 'and iv.batch_id = $7' : ''
309
+
310
+ // Calculate parameter indices
311
+ const shelfLifeParamIndex = hasBatch ? '$9' : hasWarehouse ? '$8' : '$7'
312
+
313
+ // Build lock inventory check
314
+ const lockInventoryCheck = includeLockInventoryCheck
315
+ ? `and not exists (
316
+ select 1 from inventories i
317
+ where i.domain_id = $1 and i.bizplace_id = $2 and i.product_id = $3 and i.packing_type = $4 and i.packing_size = $5 and i.uom = $6 and i.status = 'STORED' and i.lock_inventory is true
318
+ )`
319
+ : ''
320
+
321
+ const query = `
322
+ select
323
+ coalesce(sum(
324
+ case when (iv.qty - greatest(coalesce(iv.locked_qty,0),0) - greatest(coalesce(pds.unassigned_qty,0),0)) < 0
325
+ then 0
326
+ else (iv.qty - greatest(coalesce(iv.locked_qty,0),0) - greatest(coalesce(pds.unassigned_qty,0),0))
327
+ end
328
+ ), 0) as total_available_qty,
329
+ coalesce(sum(
330
+ case when (iv.uom_value - greatest(coalesce(iv.locked_uom_value,0),0) - greatest(coalesce(pds.unassigned_uom_value,0),0)) < 0
331
+ then 0
332
+ else (iv.uom_value - greatest(coalesce(iv.locked_uom_value,0),0) - greatest(coalesce(pds.unassigned_uom_value,0),0))
333
+ end
334
+ ), 0) as total_available_uom_value
335
+ from inventories iv
336
+ left join product_detail_stocks pds on pds.product_detail_id = iv.product_detail_id
337
+ inner join locations loc on loc.id = iv.location_id
338
+ ${warehouseJoin}
339
+ inner join products p on p.id = iv.product_id
340
+ where iv.domain_id = $1
341
+ and iv.bizplace_id = $2
342
+ and iv.product_id = $3
343
+ and iv.packing_type = $4
344
+ and iv.packing_size = $5
345
+ and iv.uom = $6
346
+ and iv.status = 'STORED'
347
+ and loc.type not in ('QUARANTINE','RESERVE','DAMAGE','STORAGE')
348
+ and iv.obsolete = false
349
+ and (case
350
+ when iv.expiration_date is not null
351
+ then CURRENT_DATE < iv.expiration_date - (
352
+ case
353
+ when ${shelfLifeParamIndex}::integer is not null and ${shelfLifeParamIndex}::integer > 0
354
+ then ${shelfLifeParamIndex}::integer
355
+ when p.min_outbound_shelf_life is not null
356
+ then p.min_outbound_shelf_life
357
+ else 0
358
+ end
359
+ )
360
+ else true
361
+ end)
362
+ ${batchFilter}
363
+ ${warehouseFilter}
364
+ ${lockInventoryCheck}
365
+ `
366
+
367
+ // Build parameters array
368
+ const params: any[] = [
369
+ domain.id,
370
+ customerBizplace.id,
371
+ item.product.id,
372
+ item.packingType,
373
+ item.packingSize,
374
+ item.uom
375
+ ]
376
+
377
+ if (hasBatch) {
378
+ params.push(batchId)
379
+ }
380
+ if (requireWarehouseJoin || hasWarehouse) {
381
+ params.push(warehouseName)
382
+ }
383
+ params.push(releaseShelfLifeOverride)
384
+
385
+ return { query, params }
386
+ }
387
+
388
+ // Helper function to validate inventory availability
389
+ const validateInventoryAvailability = async (item: any, query: string, params: any[], tx: EntityManager) => {
390
+ const rows = await tx.query(query, params)
391
+
392
+ const totalQty = parseFloat(rows?.[0]?.total_available_qty || '0')
393
+ const totalUomVal = parseFloat(rows?.[0]?.total_available_uom_value || '0')
394
+
395
+ let reqQty = item.releaseQty
396
+ let reqUomVal = item.releaseUomValue
397
+
398
+ if (!item.product.isInventoryDecimal) {
399
+ reqQty = Math.round(reqQty)
400
+ } else {
401
+ reqQty = Math.round(reqQty * 1000) / 1000
402
+ }
403
+
404
+ if (totalQty + 1e-6 < reqQty || totalUomVal + 1e-6 < reqUomVal) {
405
+ throw new ApiError('E01', 'INSUFFICIENT_STOCK')
406
+ }
407
+ }
408
+
238
409
  //inv without batchId will check stock in wboi
239
410
  if (invWithoutBatchId) {
240
411
  // validation
241
412
  await Promise.all(
242
413
  massagedData.combinedItems.map(async item => {
243
- const hasWarehouse = !!item.warehouseCode
244
- if (hasWarehouse) {
245
- // Validate availability within the specific warehouse across pickable locations (exclude Q/R/D/STORAGE)
246
- const rows = await tx.query(
247
- `
248
- select
249
- coalesce(sum(
250
- case when (iv.qty - greatest(coalesce(iv.locked_qty,0),0) - greatest(coalesce(pds.unassigned_qty,0),0)) < 0
251
- then 0
252
- else (iv.qty - greatest(coalesce(iv.locked_qty,0),0) - greatest(coalesce(pds.unassigned_qty,0),0))
253
- end
254
- ), 0) as total_available_qty,
255
- coalesce(sum(
256
- case when (iv.uom_value - greatest(coalesce(iv.locked_uom_value,0),0) - greatest(coalesce(pds.unassigned_uom_value,0),0)) < 0
257
- then 0
258
- else (iv.uom_value - greatest(coalesce(iv.locked_uom_value,0),0) - greatest(coalesce(pds.unassigned_uom_value,0),0))
259
- end
260
- ), 0) as total_available_uom_value
261
- from inventories iv
262
- left join product_detail_stocks pds on pds.product_detail_id = iv.product_detail_id
263
- inner join locations loc on loc.id = iv.location_id
264
- inner join warehouses w on w.id = loc.warehouse_id
265
- inner join products p on p.id = iv.product_id
266
- where iv.domain_id = $1
267
- and iv.bizplace_id = $2
268
- and iv.product_id = $3
269
- and iv.packing_type = $4
270
- and iv.packing_size = $5
271
- and iv.uom = $6
272
- and iv.status = 'STORED'
273
- and loc.type not in ('QUARANTINE','RESERVE','DAMAGE','STORAGE')
274
- and iv.obsolete = false
275
- and (case when iv.expiration_date is not null and p.min_outbound_shelf_life is not null then CURRENT_DATE < iv.expiration_date - p.min_outbound_shelf_life else true end)
276
- and w.name = $7
277
- and not exists (
278
- select 1 from inventories i
279
- where i.domain_id = $1 and i.bizplace_id = $2 and i.product_id = $3 and i.packing_type = $4 and i.packing_size = $5 and i.uom = $6 and i.status = 'STORED' and i.lock_inventory is true
280
- )
281
- `,
282
- [
283
- domain.id,
284
- customerBizplace.id,
285
- item.product.id,
286
- item.packingType,
287
- item.packingSize,
288
- item.uom,
289
- String(item.warehouseCode).trim()
290
- ]
291
- )
414
+ const warehouseName = item.warehouseCode ? String(item.warehouseCode).trim() : null
292
415
 
293
- const totalQty = parseFloat(rows?.[0]?.total_available_qty || '0')
294
- const totalUomVal = parseFloat(rows?.[0]?.total_available_uom_value || '0')
295
-
296
- let reqQty = item.releaseQty
297
- let reqUomVal = item.releaseUomValue
298
-
299
- if (!item.product.isInventoryDecimal) {
300
- reqQty = Math.round(reqQty)
301
- } else {
302
- reqQty = Math.round(reqQty * 1000) / 1000
303
- }
304
-
305
- if (totalQty + 1e-6 < reqQty || totalUomVal + 1e-6 < reqUomVal) {
306
- throw new ApiError('E01', 'INSUFFICIENT_STOCK')
307
- }
308
- } else {
309
- // Fallback to existing global view-based validation when no warehouseCode provided
310
- let itemList = await tx.query(
311
- `
312
- select wboi.*
313
- from warehouse_bizplace_onhand_inventories wboi
314
- left join (
315
- select i2.product_id, i2.domain_id, i2.bizplace_id, i2.packing_type, i2.packing_size, i2.uom,
316
- sum(i2.qty) as storage_qty,
317
- sum(i2.uom_value) as storage_uom_value
318
- from inventories i2
319
- inner join locations l2 on l2.id = i2.location_id
320
- inner join warehouses w on w.id = l2.warehouse_id
321
- where i2.domain_id = $1
322
- and i2.bizplace_id = $2
323
- and i2.product_id = $3
324
- and i2.packing_type = $4
325
- and i2.packing_size = $5
326
- and i2.uom = $6
327
- and i2.status = 'STORED'
328
- and l2.type = 'STORAGE'
329
- group by i2.product_id, i2.domain_id, i2.bizplace_id, i2.packing_type, i2.packing_size, i2.uom
330
- ) storageInv
331
- on storageInv.product_id = wboi.product_id
332
- and storageInv.domain_id = wboi.domain_id
333
- and storageInv.bizplace_id = wboi.bizplace_id
334
- and storageInv.packing_type = wboi.packing_type
335
- and storageInv.packing_size = wboi.packing_size
336
- and storageInv.uom = wboi.uom
337
- left join (
338
- select i.product_id, i.domain_id, i.bizplace_id, i.packing_type, i.packing_size, i.uom, i.lock_inventory
339
- from inventories i
340
- where i.domain_id = $1
341
- and i.bizplace_id = $2
342
- and i.product_id = $3
343
- and i.packing_type = $4
344
- and i.packing_size = $5
345
- and i.uom = $6
346
- and i.status = 'STORED'
347
- group by i.product_id, i.domain_id, i.bizplace_id, i.packing_type, i.packing_size, i.uom, i.lock_inventory
348
- ) lockInv
349
- on lockInv.product_id = wboi.product_id
350
- and lockInv.domain_id = wboi.domain_id
351
- and lockInv.bizplace_id = wboi.bizplace_id
352
- and lockInv.packing_type = wboi.packing_type
353
- and lockInv.packing_size = wboi.packing_size
354
- and lockInv.uom = wboi.uom
355
- where wboi.domain_id = $1
356
- and wboi.bizplace_id = $2
357
- and wboi.group_type = 'SINGLE'
358
- and wboi.product_id = $3
359
- and wboi.packing_type = $4
360
- and wboi.packing_size = $5
361
- and wboi.uom = $6
362
- and lockInv.lock_inventory is not true
363
- and (wboi.remain_qty - wboi.transfer_qty - coalesce(storageInv.storage_qty, 0)) >= $7
364
- and (wboi.remain_uom_value - wboi.transfer_uom_value - coalesce(storageInv.storage_uom_value, 0)) >= $8
365
- `,
366
- [
367
- domain.id,
368
- customerBizplace.id,
369
- item.product.id,
370
- item.packingType,
371
- item.packingSize,
372
- item.uom,
373
- item.releaseQty,
374
- item.releaseUomValue
375
- ]
376
- )
377
- if (itemList.length <= 0) {
378
- throw new ApiError('E01', 'INSUFFICIENT_STOCK')
379
- }
380
- console.log()
381
- }
416
+ const { query, params } = buildInventoryAvailabilityQuery(item, domain, customerBizplace, {
417
+ warehouseName,
418
+ releaseShelfLifeOverride,
419
+ requireWarehouseJoin: false,
420
+ includeLockInventoryCheck: true
421
+ })
422
+
423
+ await validateInventoryAvailability(item, query, params, tx)
382
424
  })
383
425
  )
384
426
  }
@@ -392,59 +434,15 @@ router.post(
392
434
 
393
435
  const warehouseName = item?.warehouseCode ? String(item.warehouseCode).trim() : null
394
436
 
395
- const rows = await tx.query(
396
- `
397
- select
398
- coalesce(sum(
399
- case when (iv.qty - greatest(coalesce(iv.locked_qty,0),0) - greatest(coalesce(pds.unassigned_qty,0),0)) < 0
400
- then 0
401
- else (iv.qty - greatest(coalesce(iv.locked_qty,0),0) - greatest(coalesce(pds.unassigned_qty,0),0))
402
- end
403
- ), 0) as total_available_qty
404
- from inventories iv
405
- left join product_detail_stocks pds on pds.product_detail_id = iv.product_detail_id
406
- inner join locations loc on loc.id = iv.location_id
407
- inner join warehouses w on w.id = loc.warehouse_id
408
- inner join products p on p.id = iv.product_id
409
- where iv.domain_id = $1
410
- and iv.bizplace_id = $2
411
- and iv.product_id = $3
412
- and iv.packing_type = $4
413
- and iv.packing_size = $5
414
- and iv.uom = $6
415
- and iv.status = 'STORED'
416
- and loc.type not in ('QUARANTINE','RESERVE','DAMAGE','STORAGE')
417
- and iv.obsolete = false
418
- and (case when iv.expiration_date is not null and p.min_outbound_shelf_life is not null then CURRENT_DATE < iv.expiration_date - p.min_outbound_shelf_life else true end)
419
- and iv.batch_id = $7
420
- and ( $8::text is null or w.name = $8::text )
421
- `,
422
- [
423
- domain.id,
424
- customerBizplace.id,
425
- item.product.id,
426
- item.packingType,
427
- item.packingSize,
428
- item.uom,
429
- batchId,
430
- warehouseName
431
- ]
432
- )
433
-
434
- const totalQty = parseFloat(rows?.[0]?.total_available_qty || '0')
435
-
436
- let reqQty = item.releaseQty
437
-
438
- // normalize comparison for decimal vs non-decimal products
439
- if (!item.product.isInventoryDecimal) {
440
- reqQty = Math.round(reqQty)
441
- } else {
442
- reqQty = Math.round(reqQty * 1000) / 1000
443
- }
437
+ const { query, params } = buildInventoryAvailabilityQuery(item, domain, customerBizplace, {
438
+ batchId,
439
+ warehouseName,
440
+ releaseShelfLifeOverride,
441
+ requireWarehouseJoin: true,
442
+ includeLockInventoryCheck: false
443
+ })
444
444
 
445
- if (totalQty + 1e-6 < reqQty) {
446
- throw new ApiError('E01', 'INSUFFICIENT_STOCK')
447
- }
445
+ await validateInventoryAvailability(item, query, params, tx)
448
446
  })
449
447
  )
450
448
  }
@@ -456,7 +454,8 @@ router.post(
456
454
  customerBizplace,
457
455
  context,
458
456
  tx,
459
- worksheetPickingAssignment
457
+ worksheetPickingAssignment,
458
+ releaseShelfLifeOverride
460
459
  )
461
460
  // console.timeEnd('assign')
462
461
 
@@ -765,6 +764,7 @@ async function createReleaseGood(
765
764
  : OrderNoGenerator.releaseGood(),
766
765
  domain: domain,
767
766
  bizplace: bizplace,
767
+ deliverTo: releaseGood?.deliverTo || null,
768
768
  collectionOrderNo: releaseGood.collectionOrderNo,
769
769
  courierOption: courierOption,
770
770
  codOption: releaseGood?.codOption,
@@ -1247,7 +1247,8 @@ async function assignToInventory(
1247
1247
  customerBizplace,
1248
1248
  context,
1249
1249
  tx,
1250
- worksheetPickingAssignment
1250
+ worksheetPickingAssignment,
1251
+ releaseShelfLifeOverride: number | null
1251
1252
  ) {
1252
1253
  const { domain, user } = context.state
1253
1254
 
@@ -1381,6 +1382,9 @@ async function assignToInventory(
1381
1382
  if (orderInventory[oiIdx]?.warehouseCode) {
1382
1383
  params.push(String(orderInventory[oiIdx].warehouseCode).trim())
1383
1384
  }
1385
+ // add release shelf life override parameter
1386
+ const releaseShelfLifeParamIndex = params.length + 1
1387
+ params.push(releaseShelfLifeOverride)
1384
1388
 
1385
1389
  let query = `
1386
1390
  update inventories tgt set locked_qty = coalesce(locked_qty,0) + src.reserve_qty,
@@ -1429,7 +1433,12 @@ async function assignToInventory(
1429
1433
  "iv"."domain_id" = $2 AND "iv"."bizplace_id" = $3 AND "iv"."packing_type" = $4 AND "iv"."packing_size" = $5 AND "iv"."product_id" = $6 AND "iv"."status" = $7 AND "loc"."type" NOT IN ($8, $9, $10)
1430
1434
  AND ${batchId ? `"iv"."batch_id" = $11` : `1=1`}
1431
1435
  ${warehouseNameFilter}
1432
- AND "iv"."obsolete" = false AND case when "iv"."expiration_date" is not null and "p"."min_outbound_shelf_life" is not null then CURRENT_DATE < "iv"."expiration_date" - "p"."min_outbound_shelf_life" else true end
1436
+ AND "iv"."obsolete" = false AND case
1437
+ when "iv"."expiration_date" is not null
1438
+ and coalesce($${releaseShelfLifeParamIndex}::integer, "p"."min_outbound_shelf_life") is not null
1439
+ then CURRENT_DATE < "iv"."expiration_date" - coalesce($${releaseShelfLifeParamIndex}::integer, "p"."min_outbound_shelf_life")
1440
+ else true
1441
+ end
1433
1442
  ${queryStrings.query.length > 0 ? `AND ${queryStrings.join(' AND ')}` : ''}
1434
1443
  ORDER BY wiar.rank ${sortQuery ? ', ' + sortQuery : ''}
1435
1444
  )
@@ -43,16 +43,30 @@ router.post(
43
43
  async function updateReleaseGoodDetails(tx, domain, patch, user) {
44
44
  let { releaseGood, shippingOrder } = patch
45
45
  let status = [ORDER_STATUS.DONE, ORDER_STATUS.CANCELLED, ORDER_STATUS.REJECTED]
46
+
47
+ // Build where clause: prioritize refOrderId over roId
48
+ let whereClause: any = { domain }
49
+ const refOrderId = releaseGood?.refOrderId
50
+ const hasRefOrderId = refOrderId != null && refOrderId !== ''
51
+
52
+ if (hasRefOrderId) {
53
+ whereClause.refOrderId = refOrderId
54
+ } else if (releaseGood?.roId) {
55
+ whereClause.id = releaseGood.roId
56
+ } else {
57
+ throw new ApiError('E04', 'release order: roId or refOrderId is required')
58
+ }
59
+
46
60
  let foundReleaseGood = await tx.getRepository(ReleaseGood).findOne({
47
- where: {
48
- id: releaseGood.roId,
49
- domain
50
- },
61
+ where: whereClause,
51
62
  relations: ['shippingOrder']
52
63
  })
53
64
 
54
65
  if (!foundReleaseGood) {
55
- throw new ApiError('E04', `release order: ${releaseGood.roId}`)
66
+ const searchId = hasRefOrderId
67
+ ? `refOrderId: ${refOrderId}`
68
+ : `roId: ${releaseGood?.roId}`
69
+ throw new ApiError('E04', `release order: ${searchId}`)
56
70
  }
57
71
 
58
72
  if (status.indexOf(foundReleaseGood.status) !== -1) {