@things-factory/operato-hub 4.3.701 → 4.3.708

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,155 @@ 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 (
350
+ iv.expiration_date is null
351
+ or
352
+ case
353
+ when ${shelfLifeParamIndex}::integer is not null and ${shelfLifeParamIndex}::integer > 0 then
354
+ CURRENT_DATE < iv.expiration_date - ${shelfLifeParamIndex}::integer
355
+ when p.min_outbound_shelf_life is not null and p.min_outbound_shelf_life > 0 then
356
+ CURRENT_DATE < iv.expiration_date - p.min_outbound_shelf_life
357
+ else
358
+ true
359
+ end
360
+ )
361
+ ${batchFilter}
362
+ ${warehouseFilter}
363
+ ${lockInventoryCheck}
364
+ `
365
+
366
+ // Build parameters array
367
+ const params: any[] = [
368
+ domain.id,
369
+ customerBizplace.id,
370
+ item.product.id,
371
+ item.packingType,
372
+ item.packingSize,
373
+ item.uom
374
+ ]
375
+
376
+ if (hasBatch) {
377
+ params.push(batchId)
378
+ }
379
+ if (requireWarehouseJoin || hasWarehouse) {
380
+ params.push(warehouseName)
381
+ }
382
+ params.push(releaseShelfLifeOverride)
383
+
384
+ return { query, params }
385
+ }
386
+
387
+ // Helper function to validate inventory availability
388
+ const validateInventoryAvailability = async (item: any, query: string, params: any[], tx: EntityManager) => {
389
+ const rows = await tx.query(query, params)
390
+
391
+ const totalQty = parseFloat(rows?.[0]?.total_available_qty || '0')
392
+ const totalUomVal = parseFloat(rows?.[0]?.total_available_uom_value || '0')
393
+
394
+ let reqQty = item.releaseQty
395
+ let reqUomVal = item.releaseUomValue
396
+
397
+ if (!item.product.isInventoryDecimal) {
398
+ reqQty = Math.round(reqQty)
399
+ } else {
400
+ reqQty = Math.round(reqQty * 1000) / 1000
401
+ }
402
+
403
+ if (totalQty + 1e-6 < reqQty || totalUomVal + 1e-6 < reqUomVal) {
404
+ throw new ApiError('E01', 'INSUFFICIENT_STOCK')
405
+ }
406
+ }
407
+
238
408
  //inv without batchId will check stock in wboi
239
409
  if (invWithoutBatchId) {
240
410
  // validation
241
411
  await Promise.all(
242
412
  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
- )
413
+ const warehouseName = item.warehouseCode ? String(item.warehouseCode).trim() : null
292
414
 
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
- }
415
+ const { query, params } = buildInventoryAvailabilityQuery(item, domain, customerBizplace, {
416
+ warehouseName,
417
+ releaseShelfLifeOverride,
418
+ requireWarehouseJoin: false,
419
+ includeLockInventoryCheck: true
420
+ })
421
+
422
+ await validateInventoryAvailability(item, query, params, tx)
382
423
  })
383
424
  )
384
425
  }
@@ -392,59 +433,15 @@ router.post(
392
433
 
393
434
  const warehouseName = item?.warehouseCode ? String(item.warehouseCode).trim() : null
394
435
 
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')
436
+ const { query, params } = buildInventoryAvailabilityQuery(item, domain, customerBizplace, {
437
+ batchId,
438
+ warehouseName,
439
+ releaseShelfLifeOverride,
440
+ requireWarehouseJoin: true,
441
+ includeLockInventoryCheck: false
442
+ })
435
443
 
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
- }
444
-
445
- if (totalQty + 1e-6 < reqQty) {
446
- throw new ApiError('E01', 'INSUFFICIENT_STOCK')
447
- }
444
+ await validateInventoryAvailability(item, query, params, tx)
448
445
  })
449
446
  )
450
447
  }
@@ -456,7 +453,8 @@ router.post(
456
453
  customerBizplace,
457
454
  context,
458
455
  tx,
459
- worksheetPickingAssignment
456
+ worksheetPickingAssignment,
457
+ releaseShelfLifeOverride
460
458
  )
461
459
  // console.timeEnd('assign')
462
460
 
@@ -765,6 +763,7 @@ async function createReleaseGood(
765
763
  : OrderNoGenerator.releaseGood(),
766
764
  domain: domain,
767
765
  bizplace: bizplace,
766
+ deliverTo: releaseGood?.deliverTo || null,
768
767
  collectionOrderNo: releaseGood.collectionOrderNo,
769
768
  courierOption: courierOption,
770
769
  codOption: releaseGood?.codOption,
@@ -1247,7 +1246,8 @@ async function assignToInventory(
1247
1246
  customerBizplace,
1248
1247
  context,
1249
1248
  tx,
1250
- worksheetPickingAssignment
1249
+ worksheetPickingAssignment,
1250
+ releaseShelfLifeOverride: number | null
1251
1251
  ) {
1252
1252
  const { domain, user } = context.state
1253
1253
 
@@ -1381,6 +1381,9 @@ async function assignToInventory(
1381
1381
  if (orderInventory[oiIdx]?.warehouseCode) {
1382
1382
  params.push(String(orderInventory[oiIdx].warehouseCode).trim())
1383
1383
  }
1384
+ // add release shelf life override parameter
1385
+ const releaseShelfLifeParamIndex = params.length + 1
1386
+ params.push(releaseShelfLifeOverride)
1384
1387
 
1385
1388
  let query = `
1386
1389
  update inventories tgt set locked_qty = coalesce(locked_qty,0) + src.reserve_qty,
@@ -1429,7 +1432,18 @@ async function assignToInventory(
1429
1432
  "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
1433
  AND ${batchId ? `"iv"."batch_id" = $11` : `1=1`}
1431
1434
  ${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
1435
+ AND "iv"."obsolete" = false AND (
1436
+ "iv"."expiration_date" IS NULL
1437
+ OR
1438
+ CASE
1439
+ WHEN $${releaseShelfLifeParamIndex}::integer IS NOT NULL AND $${releaseShelfLifeParamIndex}::integer > 0 THEN
1440
+ CURRENT_DATE < ("iv"."expiration_date" - $${releaseShelfLifeParamIndex}::integer)
1441
+ WHEN "p"."min_outbound_shelf_life" IS NOT NULL AND "p"."min_outbound_shelf_life" > 0 THEN
1442
+ CURRENT_DATE < ("iv"."expiration_date" - "p"."min_outbound_shelf_life")
1443
+ ELSE
1444
+ TRUE
1445
+ END
1446
+ )
1433
1447
  ${queryStrings.query.length > 0 ? `AND ${queryStrings.join(' AND ')}` : ''}
1434
1448
  ORDER BY wiar.rank ${sortQuery ? ', ' + sortQuery : ''}
1435
1449
  )
@@ -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) {