@things-factory/sales-base 4.3.701 → 4.3.723

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.
@@ -22,6 +22,7 @@ import {
22
22
  getOutletBizplace,
23
23
  getPermittedBizplaces
24
24
  } from '@things-factory/biz-base'
25
+ import { ContactPoint } from '@things-factory/biz-base'
25
26
  import { GeoCountry } from '@things-factory/geography'
26
27
  import { generateId } from '@things-factory/id-rule-base'
27
28
  import { WebhookEventsEnum, webhookHandler } from '@things-factory/integration-base'
@@ -30,7 +31,7 @@ import { MarketplaceStore } from '@things-factory/integration-marketplace'
30
31
  import { Sellercraft, SellercraftStatus } from '@things-factory/integration-sellercraft'
31
32
  import { MarketplaceOrder } from '@things-factory/marketplace-base'
32
33
  // import { sendNotification } from '@things-factory/notification'
33
- import { ProductBundleSetting, ProductDetail } from '@things-factory/product-base'
34
+ import { Product, ProductBundleSetting, ProductDetail } from '@things-factory/product-base'
34
35
  import { PartnerSetting, Setting } from '@things-factory/setting-base'
35
36
  import { Domain } from '@things-factory/shell'
36
37
  import { Inventory, ProductDetailStock } from '@things-factory/warehouse-base'
@@ -620,6 +621,159 @@ export async function generateReleaseGoodFunction(
620
621
  await InventoryUtil.validateWarehousePartnersProductsQuantity(domain, bizplace, orderInventories, context, tx)
621
622
  /** End Validate Release Order Product Quantity Section */
622
623
 
624
+ /**
625
+ * Enforce outbound shelf-life at submission time using deliverTo.releaseShelfLife (if present).
626
+ * This guards scenarios where user selected inventories first and then set Contact Point,
627
+ * ensuring final validation rejects items that don't meet the shelf-life requirement.
628
+ */
629
+ let outboundShelfLifeOverride: number | null = null
630
+ let contactPoint: ContactPoint = null
631
+ const deliverToId: string | null =
632
+ typeof releaseGood?.deliverTo === 'string' && releaseGood?.deliverTo ? String(releaseGood.deliverTo) : null
633
+ if (deliverToId) {
634
+ contactPoint = await tx.getRepository(ContactPoint).findOne({ where: { id: deliverToId } })
635
+ if (contactPoint?.releaseShelfLife != null && contactPoint.releaseShelfLife !== 0) {
636
+ outboundShelfLifeOverride = Number(contactPoint.releaseShelfLife)
637
+ }
638
+ }
639
+
640
+ if (orderInventories?.length) {
641
+ // Helper function to build inventory availability query with optional warehouse and batch filters
642
+ const buildInventoryAvailabilityQuery = (
643
+ productId: string,
644
+ packingType: string,
645
+ packingSize: number,
646
+ uom: string,
647
+ warehouseName: string | null,
648
+ batchId: string | null,
649
+ outboundShelfLifeOverride: number | null
650
+ ) => {
651
+ const query = `
652
+ select
653
+ coalesce(sum(
654
+ case when (iv.qty - greatest(coalesce(iv.locked_qty,0),0) - greatest(coalesce(pds.unassigned_qty,0),0)) < 0
655
+ then 0
656
+ else (iv.qty - greatest(coalesce(iv.locked_qty,0),0) - greatest(coalesce(pds.unassigned_qty,0),0))
657
+ end
658
+ ), 0) as total_available_qty,
659
+ coalesce(sum(
660
+ case when (iv.uom_value - greatest(coalesce(iv.locked_uom_value,0),0) - greatest(coalesce(pds.unassigned_uom_value,0),0)) < 0
661
+ then 0
662
+ else (iv.uom_value - greatest(coalesce(iv.locked_uom_value,0),0) - greatest(coalesce(pds.unassigned_uom_value,0),0))
663
+ end
664
+ ), 0) as total_available_uom_value,
665
+ max(p.sku) as product_sku
666
+ from inventories iv
667
+ left join product_detail_stocks pds on pds.product_detail_id = iv.product_detail_id
668
+ inner join products p on p.id = iv.product_id
669
+ inner join locations loc on loc.id = iv.location_id
670
+ inner join warehouses w on w.id = loc.warehouse_id
671
+ where iv.domain_id = $1
672
+ and iv.bizplace_id = $2
673
+ and iv.product_id = $3
674
+ and iv.packing_type = $4
675
+ and iv.packing_size = $5
676
+ and iv.uom = $6
677
+ and iv.status = 'STORED'
678
+ and loc.type not in ('QUARANTINE','RESERVE','DAMAGE')
679
+ and iv.obsolete = false
680
+ and (
681
+ iv.expiration_date is null
682
+ or
683
+ case
684
+ when $9::integer is not null and $9::integer > 0 then
685
+ CURRENT_DATE < iv.expiration_date - $9::integer
686
+ when p.min_outbound_shelf_life is not null and p.min_outbound_shelf_life > 0 then
687
+ CURRENT_DATE < iv.expiration_date - p.min_outbound_shelf_life
688
+ else
689
+ true
690
+ end
691
+ )
692
+ and ($7::text is null or w.name = $7::text)
693
+ and ($8::text is null or iv.batch_id = $8::text)
694
+ `
695
+
696
+ const params = [
697
+ domain.id,
698
+ bizplace.id,
699
+ productId,
700
+ packingType,
701
+ packingSize,
702
+ uom,
703
+ warehouseName,
704
+ batchId,
705
+ outboundShelfLifeOverride
706
+ ]
707
+
708
+ return { query, params }
709
+ }
710
+
711
+ const insufficientItems: any[] = []
712
+ for (const item of orderInventories) {
713
+ // Only validate standard (non-bundle) rows that have product/productDetail context
714
+ if (!item?.productDetail?.id || !item?.uom) continue
715
+
716
+ const productDetailId = item.productDetail.id
717
+ const productId = item?.product?.id || item?.productDetail?.product?.id
718
+ const packingType = item.packingType
719
+ const packingSize = item.packingSize
720
+ const uom = item.uom
721
+ const warehouseName = (item as any)?.warehouseCode ? String((item as any).warehouseCode).trim() : null
722
+ const batchId = item?.batchId && item.batchId !== '-' ? String(item.batchId).trim() : null
723
+
724
+ // Validate availability with shelf-life predicate applied
725
+ const { query, params } = buildInventoryAvailabilityQuery(
726
+ productId,
727
+ packingType,
728
+ packingSize,
729
+ uom,
730
+ warehouseName,
731
+ batchId,
732
+ outboundShelfLifeOverride
733
+ )
734
+
735
+ const rows = await tx.query(query, params)
736
+
737
+ const totalQty = parseFloat(rows?.[0]?.total_available_qty || '0')
738
+ // Normalize release quantity for non-decimal products
739
+ let reqQty = item.releaseQty
740
+ if ((item as any)?.product?.isInventoryDecimal === false) {
741
+ reqQty = Math.round(reqQty)
742
+ } else {
743
+ reqQty = Math.round(reqQty * 1000) / 1000
744
+ }
745
+
746
+ let productSku: string = rows?.[0]?.product_sku || null
747
+ if (!productSku && productId) {
748
+ const prod: Product = await tx
749
+ .getRepository(Product)
750
+ .findOne({ where: { id: productId }, select: ['id', 'sku'] })
751
+ if (prod?.sku) {
752
+ productSku = prod.sku
753
+ }
754
+ }
755
+ if (totalQty + 1e-6 < reqQty) {
756
+ insufficientItems.push({
757
+ productId,
758
+ productDetailId,
759
+ productSku: productSku,
760
+ packingType,
761
+ packingSize,
762
+ uom,
763
+ batchId,
764
+ requiredQty: reqQty,
765
+ availableQty: totalQty
766
+ })
767
+ }
768
+ }
769
+ if (insufficientItems.length > 0) {
770
+ throw new ValidationError({
771
+ ...ValidationError.ERROR_CODES.INSUFFICIENT_STOCK,
772
+ detail: { data: JSON.stringify(insufficientItems) }
773
+ })
774
+ }
775
+ }
776
+
623
777
  const orderSource: string = releaseGood.source
624
778
  switch (orderSource) {
625
779
  case ApplicationType.SELLERCRAFT:
@@ -801,6 +955,7 @@ export async function generateReleaseGoodFunction(
801
955
  lastMileDelivery: lmd || null,
802
956
  codOption: releaseGood?.codOption || null,
803
957
  paidAmount: releaseGood?.paidAmount || null,
958
+ deliverTo: contactPoint || null,
804
959
  creator: user,
805
960
  updater: user
806
961
  }
@@ -948,7 +1103,13 @@ export async function generateReleaseGoodFunction(
948
1103
  bizplace,
949
1104
  warehouseDomain,
950
1105
  tx,
951
- newOrderInv.batchId
1106
+ newOrderInv.batchId,
1107
+ undefined,
1108
+ undefined,
1109
+ undefined,
1110
+ undefined,
1111
+ undefined,
1112
+ outboundShelfLifeOverride
952
1113
  )
953
1114
 
954
1115
  assignedOrderInventories = assignedOrderInventories.map(aoi => {
@@ -1681,6 +1842,21 @@ export async function bulkGenerateReleaseGood(
1681
1842
  newReleaseGood.shippingOrder = createdSO
1682
1843
  }
1683
1844
 
1845
+ /**
1846
+ * Enforce outbound shelf-life at submission time using deliverTo.releaseShelfLife (if present).
1847
+ * This guards scenarios where user selected inventories first and then set Contact Point,
1848
+ * ensuring final validation rejects items that don't meet the shelf-life requirement.
1849
+ */
1850
+ let outboundShelfLifeOverride: number | null = null
1851
+ const deliverToId: string | null =
1852
+ typeof releaseGood?.deliverTo === 'string' && releaseGood?.deliverTo ? String(releaseGood.deliverTo) : null
1853
+ if (deliverToId) {
1854
+ const contactPoint: ContactPoint = await tx.getRepository(ContactPoint).findOne({ where: { id: deliverToId } })
1855
+ if (contactPoint?.releaseShelfLife != null && contactPoint.releaseShelfLife !== 0) {
1856
+ outboundShelfLifeOverride = Number(contactPoint.releaseShelfLife)
1857
+ }
1858
+ }
1859
+
1684
1860
  let finalOrderInventories: Partial<OrderInventory>[] = []
1685
1861
 
1686
1862
  // pre assign inventory to orderInventories
@@ -1717,7 +1893,9 @@ export async function bulkGenerateReleaseGood(
1717
1893
  '',
1718
1894
  null,
1719
1895
  oi.cartonId,
1720
- oi.expirationDate
1896
+ oi.expirationDate,
1897
+ undefined,
1898
+ outboundShelfLifeOverride
1721
1899
  )
1722
1900
 
1723
1901
  finalOrderInventories.push(
@@ -1976,7 +2154,7 @@ function extractRawReleaseGoods(rawReleaseGoods): Partial<ReleaseGood[]> {
1976
2154
  packingSize: item.packingSize,
1977
2155
  uom: item.uom,
1978
2156
  releaseQty: Math.round(parseFloat(item.releaseQty) * 1000) / 1000,
1979
- releaseUomValue: Math.round(parseFloat(item.releaseUomValue) * 1000) / 1000,
2157
+ releaseUomValue: Math.round(parseFloat(item.releaseUomValue) * 1000) / 1000
1980
2158
  })
1981
2159
  }
1982
2160
  } else {
@@ -952,6 +952,13 @@ export async function bulkReleaseGoodsAvailableItemsFunction(
952
952
  const companyBizplaceId: Bizplace = await getCompanyBizplace(null, null, bizplaceId)
953
953
 
954
954
  if (!rawReleaseGoods) return
955
+ // derive optional release shelf life override (use the first non-null value if provided)
956
+ const releaseShelfLifeOverride: number | null = (() => {
957
+ const found = (rawReleaseGoods || []).find(
958
+ (r: any) => r?.releaseShelfLifeOverride !== undefined && r?.releaseShelfLifeOverride !== null
959
+ )
960
+ return found ? Number(found.releaseShelfLifeOverride) : null
961
+ })()
955
962
  // derive optional filters
956
963
  const uniqueWarehouseCodes: string[] = Array.from(
957
964
  new Set(
@@ -1067,6 +1074,18 @@ export async function bulkReleaseGoodsAvailableItemsFunction(
1067
1074
  AND i.transfer_uom_value <= 0
1068
1075
  AND ( $5::text IS NULL OR w.name = $5 )
1069
1076
  ${`AND ( $6::text IS NULL OR i.batch_id = $6 )`}
1077
+ AND (
1078
+ i.expiration_date IS NULL
1079
+ OR
1080
+ CASE
1081
+ WHEN $7::integer IS NOT NULL AND $7::integer > 0 THEN
1082
+ CURRENT_DATE < (i.expiration_date - $7::integer)
1083
+ WHEN p.min_outbound_shelf_life IS NOT NULL AND p.min_outbound_shelf_life > 0 THEN
1084
+ CURRENT_DATE < (i.expiration_date - p.min_outbound_shelf_life)
1085
+ ELSE
1086
+ TRUE
1087
+ END
1088
+ )
1070
1089
  GROUP BY i.product_id, foo.product_detail_id, foo.sku, foo.product_info, i.packing_type, i.packing_size, i.uom, p.is_inventory_decimal${
1071
1090
  groupByWarehouse ? ', w.name' : ''
1072
1091
  }
@@ -1080,7 +1099,8 @@ export async function bulkReleaseGoodsAvailableItemsFunction(
1080
1099
  LOCATION_TYPE.QUARANTINE,
1081
1100
  LOCATION_TYPE.RESERVE,
1082
1101
  uniqueWarehouseCodes.length === 1 ? uniqueWarehouseCodes[0] : null,
1083
- uniqueBatchIds.length === 1 ? uniqueBatchIds[0] : null
1102
+ uniqueBatchIds.length === 1 ? uniqueBatchIds[0] : null,
1103
+ releaseShelfLifeOverride
1084
1104
  ]
1085
1105
  )
1086
1106
 
@@ -78,6 +78,8 @@ export const InventoryUtil = {
78
78
  }
79
79
  }
80
80
 
81
+ // optional override for outbound shelf life (from ContactPoint deliverTo)
82
+ const releaseShelfLifeOverrideFilter = filters.find(f => f.name === 'releaseShelfLifeOverride')
81
83
  let queryStrings = `
82
84
  CREATE TEMP TABLE temp_inventory_product_group ON COMMIT DROP AS (
83
85
  SELECT * FROM (
@@ -163,7 +165,18 @@ export const InventoryUtil = {
163
165
  ) bp on i.product_id = bp.product_id
164
166
  WHERE i.bizplace_id IN (${bizplaceIds})
165
167
  AND i.lock_inventory is not true
166
- AND case when i.expiration_date is not null and p.min_outbound_shelf_life is not null then CURRENT_DATE < i.expiration_date - p.min_outbound_shelf_life else true end
168
+ AND (
169
+ i.expiration_date IS NULL
170
+ OR
171
+ CASE
172
+ WHEN $2::integer IS NOT NULL AND $2::integer > 0 THEN
173
+ CURRENT_DATE < (i.expiration_date - $2::integer)
174
+ WHEN p.min_outbound_shelf_life IS NOT NULL AND p.min_outbound_shelf_life > 0 THEN
175
+ CURRENT_DATE < (i.expiration_date - p.min_outbound_shelf_life)
176
+ ELSE
177
+ TRUE
178
+ END
179
+ )
167
180
  ${productDetailWhereClause}
168
181
  ${
169
182
  productFilter
@@ -252,7 +265,10 @@ export const InventoryUtil = {
252
265
  )
253
266
  `
254
267
 
255
- await trxMgr.query(queryStrings, [domain.id])
268
+ await trxMgr.query(queryStrings, [
269
+ domain.id,
270
+ releaseShelfLifeOverrideFilter ? releaseShelfLifeOverrideFilter.value : null
271
+ ])
256
272
 
257
273
  const [{ total }]: any = await trxMgr.query(`select count(*) as total from temp_inventory_product_group`)
258
274
  let items: any[] = []
@@ -295,6 +311,7 @@ export const InventoryUtil = {
295
311
  let cycleCountFilter = filters.find(filter => filter.name == 'cycleCount')?.value
296
312
 
297
313
  const _groupType = filters.find(res => res.name == 'groupType')
314
+ const releaseShelfLifeOverrideFilter = filters.find(filter => filter.name === 'releaseShelfLifeOverride')
298
315
  let queryStrings = `
299
316
  CREATE TEMP TABLE temp_inventory_product_group AS (
300
317
  SELECT * FROM (
@@ -335,7 +352,18 @@ export const InventoryUtil = {
335
352
  AND i.transfer_qty <= 0
336
353
  AND i.transfer_uom_value <= 0
337
354
  AND i.lock_inventory is not true
338
- AND CASE WHEN i.expiration_date IS NOT NULL AND p.min_outbound_shelf_life IS NOT NULL THEN CURRENT_DATE < i.expiration_date - p.min_outbound_shelf_life ELSE true END
355
+ AND (
356
+ i.expiration_date IS NULL
357
+ OR
358
+ CASE
359
+ WHEN $2::integer IS NOT NULL AND $2::integer > 0 THEN
360
+ CURRENT_DATE < i.expiration_date - $2::integer
361
+ WHEN p.min_outbound_shelf_life IS NOT NULL AND p.min_outbound_shelf_life > 0 THEN
362
+ CURRENT_DATE < i.expiration_date - p.min_outbound_shelf_life
363
+ ELSE
364
+ TRUE
365
+ END
366
+ )
339
367
  ${
340
368
  cycleCountFilter
341
369
  ? `AND i.id NOT IN (
@@ -425,7 +453,10 @@ export const InventoryUtil = {
425
453
  })
426
454
  }
427
455
 
428
- await trxMgr.query(queryStrings, [domain.id])
456
+ await trxMgr.query(queryStrings, [
457
+ domain.id,
458
+ releaseShelfLifeOverrideFilter ? releaseShelfLifeOverrideFilter.value : null
459
+ ])
429
460
 
430
461
  const [{ total }]: any = await trxMgr.query(
431
462
  `select count(*) as total from temp_inventory_product_group ${filterGroupTypeQuery ? filterGroupTypeQuery : ''}`
@@ -512,7 +543,16 @@ export const InventoryUtil = {
512
543
  AND i.transfer_qty <= 0
513
544
  AND i.transfer_uom_value <= 0
514
545
  AND i.lock_inventory is not true
515
- AND CASE WHEN i.expiration_date IS NOT NULL AND p.min_outbound_shelf_life IS NOT NULL THEN CURRENT_DATE < i.expiration_date - p.min_outbound_shelf_life ELSE true END
546
+ AND (
547
+ i.expiration_date IS NULL
548
+ OR
549
+ CASE
550
+ WHEN p.min_outbound_shelf_life IS NOT NULL AND p.min_outbound_shelf_life > 0 THEN
551
+ CURRENT_DATE < i.expiration_date - p.min_outbound_shelf_life
552
+ ELSE
553
+ TRUE
554
+ END
555
+ )
516
556
  ${productWhereClause}
517
557
  GROUP BY
518
558
  i.product_detail_id,
@@ -716,7 +756,16 @@ export const InventoryUtil = {
716
756
  WHERE i.domain_id = $1
717
757
  AND l2.type NOT IN ('${LOCATION_TYPE.QUARANTINE}', '${LOCATION_TYPE.RESERVE}')
718
758
  AND i.obsolete = false
719
- AND case when i.expiration_date is not null and p.min_outbound_shelf_life is not null then CURRENT_DATE < i.expiration_date - p.min_outbound_shelf_life else true end
759
+ AND (
760
+ i.expiration_date IS NULL
761
+ OR
762
+ CASE
763
+ WHEN p.min_outbound_shelf_life IS NOT NULL AND p.min_outbound_shelf_life > 0 THEN
764
+ CURRENT_DATE < i.expiration_date - p.min_outbound_shelf_life
765
+ ELSE
766
+ TRUE
767
+ END
768
+ )
720
769
  ${apiWhereClause}
721
770
  GROUP BY
722
771
  p.id,
@@ -873,7 +922,8 @@ export const InventoryUtil = {
873
922
  recall: boolean = null,
874
923
  cartonId?: string,
875
924
  expirationDate?: Date,
876
- warehouseName?: string
925
+ warehouseName?: string,
926
+ outboundShelfLifeOverride?: number
877
927
  ): Promise<OrderInventory[]> {
878
928
  let strictProduct = 'false'
879
929
 
@@ -918,14 +968,26 @@ export const InventoryUtil = {
918
968
  .andWhere('"iv"."transfer_uom_value" <= 0')
919
969
  .andWhere('"iv"."product_id" = :productId')
920
970
  .andWhere(
921
- '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'
971
+ `(
972
+ "iv"."expiration_date" IS NULL
973
+ OR
974
+ CASE
975
+ WHEN :outboundShelfLifeOverride::integer IS NOT NULL AND :outboundShelfLifeOverride::integer > 0 THEN
976
+ CURRENT_DATE < ("iv"."expiration_date" - :outboundShelfLifeOverride::integer)
977
+ WHEN "p"."min_outbound_shelf_life" IS NOT NULL AND "p"."min_outbound_shelf_life" > 0 THEN
978
+ CURRENT_DATE < ("iv"."expiration_date" - "p"."min_outbound_shelf_life")
979
+ ELSE
980
+ TRUE
981
+ END
982
+ )`
922
983
  )
923
984
  .setParameters({
924
985
  domainId: domain.id,
925
986
  bizplaceId: customerBizplace.id,
926
987
  productId: product.id,
927
988
  status: INVENTORY_STATUS.STORED,
928
- locationTypes
989
+ locationTypes,
990
+ outboundShelfLifeOverride: outboundShelfLifeOverride ?? null
929
991
  })
930
992
 
931
993
  if (batchId) {