@things-factory/sales-base 4.3.695 → 4.3.700

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,162 @@ 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','STORAGE')
679
+ and iv.obsolete = false
680
+ and (
681
+ case
682
+ when iv.expiration_date is not null
683
+ then CURRENT_DATE < iv.expiration_date - (
684
+ case
685
+ when $9::integer is not null and $9::integer > 0
686
+ then $9::integer
687
+ when p.min_outbound_shelf_life is not null
688
+ then p.min_outbound_shelf_life
689
+ else 0
690
+ end
691
+ )
692
+ else true
693
+ end
694
+ )
695
+ and ($7::text is null or w.name = $7::text)
696
+ and ($8::text is null or iv.batch_id = $8::text)
697
+ `
698
+
699
+ const params = [
700
+ domain.id,
701
+ bizplace.id,
702
+ productId,
703
+ packingType,
704
+ packingSize,
705
+ uom,
706
+ warehouseName,
707
+ batchId,
708
+ outboundShelfLifeOverride
709
+ ]
710
+
711
+ return { query, params }
712
+ }
713
+
714
+ const insufficientItems: any[] = []
715
+ for (const item of orderInventories) {
716
+ // Only validate standard (non-bundle) rows that have product/productDetail context
717
+ if (!item?.productDetail?.id || !item?.uom) continue
718
+
719
+ const productDetailId = item.productDetail.id
720
+ const productId = item?.product?.id || item?.productDetail?.product?.id
721
+ const packingType = item.packingType
722
+ const packingSize = item.packingSize
723
+ const uom = item.uom
724
+ const warehouseName = (item as any)?.warehouseCode ? String((item as any).warehouseCode).trim() : null
725
+ const batchId = item?.batchId && item.batchId !== '-' ? String(item.batchId).trim() : null
726
+
727
+ // Validate availability with shelf-life predicate applied
728
+ const { query, params } = buildInventoryAvailabilityQuery(
729
+ productId,
730
+ packingType,
731
+ packingSize,
732
+ uom,
733
+ warehouseName,
734
+ batchId,
735
+ outboundShelfLifeOverride
736
+ )
737
+
738
+ const rows = await tx.query(query, params)
739
+
740
+ const totalQty = parseFloat(rows?.[0]?.total_available_qty || '0')
741
+ // Normalize release quantity for non-decimal products
742
+ let reqQty = item.releaseQty
743
+ if ((item as any)?.product?.isInventoryDecimal === false) {
744
+ reqQty = Math.round(reqQty)
745
+ } else {
746
+ reqQty = Math.round(reqQty * 1000) / 1000
747
+ }
748
+
749
+ let productSku: string = rows?.[0]?.product_sku || null
750
+ if (!productSku && productId) {
751
+ const prod: Product = await tx
752
+ .getRepository(Product)
753
+ .findOne({ where: { id: productId }, select: ['id', 'sku'] })
754
+ if (prod?.sku) {
755
+ productSku = prod.sku
756
+ }
757
+ }
758
+ if (totalQty + 1e-6 < reqQty) {
759
+ insufficientItems.push({
760
+ productId,
761
+ productDetailId,
762
+ productSku: productSku,
763
+ packingType,
764
+ packingSize,
765
+ uom,
766
+ batchId,
767
+ requiredQty: reqQty,
768
+ availableQty: totalQty
769
+ })
770
+ }
771
+ }
772
+ if (insufficientItems.length > 0) {
773
+ throw new ValidationError({
774
+ ...ValidationError.ERROR_CODES.INSUFFICIENT_STOCK,
775
+ detail: { data: JSON.stringify(insufficientItems) }
776
+ })
777
+ }
778
+ }
779
+
623
780
  const orderSource: string = releaseGood.source
624
781
  switch (orderSource) {
625
782
  case ApplicationType.SELLERCRAFT:
@@ -801,6 +958,7 @@ export async function generateReleaseGoodFunction(
801
958
  lastMileDelivery: lmd || null,
802
959
  codOption: releaseGood?.codOption || null,
803
960
  paidAmount: releaseGood?.paidAmount || null,
961
+ deliverTo: contactPoint || null,
804
962
  creator: user,
805
963
  updater: user
806
964
  }
@@ -948,7 +1106,13 @@ export async function generateReleaseGoodFunction(
948
1106
  bizplace,
949
1107
  warehouseDomain,
950
1108
  tx,
951
- newOrderInv.batchId
1109
+ newOrderInv.batchId,
1110
+ undefined,
1111
+ undefined,
1112
+ undefined,
1113
+ undefined,
1114
+ undefined,
1115
+ outboundShelfLifeOverride
952
1116
  )
953
1117
 
954
1118
  assignedOrderInventories = assignedOrderInventories.map(aoi => {
@@ -1681,6 +1845,21 @@ export async function bulkGenerateReleaseGood(
1681
1845
  newReleaseGood.shippingOrder = createdSO
1682
1846
  }
1683
1847
 
1848
+ /**
1849
+ * Enforce outbound shelf-life at submission time using deliverTo.releaseShelfLife (if present).
1850
+ * This guards scenarios where user selected inventories first and then set Contact Point,
1851
+ * ensuring final validation rejects items that don't meet the shelf-life requirement.
1852
+ */
1853
+ let outboundShelfLifeOverride: number | null = null
1854
+ const deliverToId: string | null =
1855
+ typeof releaseGood?.deliverTo === 'string' && releaseGood?.deliverTo ? String(releaseGood.deliverTo) : null
1856
+ if (deliverToId) {
1857
+ const contactPoint: ContactPoint = await tx.getRepository(ContactPoint).findOne({ where: { id: deliverToId } })
1858
+ if (contactPoint?.releaseShelfLife != null && contactPoint.releaseShelfLife !== 0) {
1859
+ outboundShelfLifeOverride = Number(contactPoint.releaseShelfLife)
1860
+ }
1861
+ }
1862
+
1684
1863
  let finalOrderInventories: Partial<OrderInventory>[] = []
1685
1864
 
1686
1865
  // pre assign inventory to orderInventories
@@ -1717,7 +1896,9 @@ export async function bulkGenerateReleaseGood(
1717
1896
  '',
1718
1897
  null,
1719
1898
  oi.cartonId,
1720
- oi.expirationDate
1899
+ oi.expirationDate,
1900
+ undefined,
1901
+ outboundShelfLifeOverride
1721
1902
  )
1722
1903
 
1723
1904
  finalOrderInventories.push(
@@ -1976,7 +2157,7 @@ function extractRawReleaseGoods(rawReleaseGoods): Partial<ReleaseGood[]> {
1976
2157
  packingSize: item.packingSize,
1977
2158
  uom: item.uom,
1978
2159
  releaseQty: Math.round(parseFloat(item.releaseQty) * 1000) / 1000,
1979
- releaseUomValue: Math.round(parseFloat(item.releaseUomValue) * 1000) / 1000,
2160
+ releaseUomValue: Math.round(parseFloat(item.releaseUomValue) * 1000) / 1000
1980
2161
  })
1981
2162
  }
1982
2163
  } 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,13 @@ 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
+ CASE
1079
+ WHEN i.expiration_date IS NOT NULL AND COALESCE($7::integer, p.min_outbound_shelf_life) IS NOT NULL
1080
+ THEN CURRENT_DATE < i.expiration_date - COALESCE($7::integer, p.min_outbound_shelf_life)
1081
+ ELSE TRUE
1082
+ END
1083
+ )
1070
1084
  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
1085
  groupByWarehouse ? ', w.name' : ''
1072
1086
  }
@@ -1080,7 +1094,8 @@ export async function bulkReleaseGoodsAvailableItemsFunction(
1080
1094
  LOCATION_TYPE.QUARANTINE,
1081
1095
  LOCATION_TYPE.RESERVE,
1082
1096
  uniqueWarehouseCodes.length === 1 ? uniqueWarehouseCodes[0] : null,
1083
- uniqueBatchIds.length === 1 ? uniqueBatchIds[0] : null
1097
+ uniqueBatchIds.length === 1 ? uniqueBatchIds[0] : null,
1098
+ releaseShelfLifeOverride
1084
1099
  ]
1085
1100
  )
1086
1101
 
@@ -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,19 @@ 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 case
169
+ when i.expiration_date is not null
170
+ then CURRENT_DATE < i.expiration_date - (
171
+ case
172
+ when $2::integer is not null and $2::integer > 0
173
+ then $2::integer
174
+ when p.min_outbound_shelf_life is not null
175
+ then p.min_outbound_shelf_life
176
+ else 0
177
+ end
178
+ )
179
+ else true
180
+ end
167
181
  ${productDetailWhereClause}
168
182
  ${
169
183
  productFilter
@@ -252,7 +266,10 @@ export const InventoryUtil = {
252
266
  )
253
267
  `
254
268
 
255
- await trxMgr.query(queryStrings, [domain.id])
269
+ await trxMgr.query(queryStrings, [
270
+ domain.id,
271
+ releaseShelfLifeOverrideFilter ? releaseShelfLifeOverrideFilter.value : null
272
+ ])
256
273
 
257
274
  const [{ total }]: any = await trxMgr.query(`select count(*) as total from temp_inventory_product_group`)
258
275
  let items: any[] = []
@@ -295,6 +312,7 @@ export const InventoryUtil = {
295
312
  let cycleCountFilter = filters.find(filter => filter.name == 'cycleCount')?.value
296
313
 
297
314
  const _groupType = filters.find(res => res.name == 'groupType')
315
+ const releaseShelfLifeOverrideFilter = filters.find(filter => filter.name === 'releaseShelfLifeOverride')
298
316
  let queryStrings = `
299
317
  CREATE TEMP TABLE temp_inventory_product_group AS (
300
318
  SELECT * FROM (
@@ -335,7 +353,7 @@ export const InventoryUtil = {
335
353
  AND i.transfer_qty <= 0
336
354
  AND i.transfer_uom_value <= 0
337
355
  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
356
+ AND CASE WHEN i.expiration_date IS NOT NULL AND COALESCE($2::integer, p.min_outbound_shelf_life) IS NOT NULL THEN CURRENT_DATE < i.expiration_date - COALESCE($2::integer, p.min_outbound_shelf_life) ELSE true END
339
357
  ${
340
358
  cycleCountFilter
341
359
  ? `AND i.id NOT IN (
@@ -425,7 +443,10 @@ export const InventoryUtil = {
425
443
  })
426
444
  }
427
445
 
428
- await trxMgr.query(queryStrings, [domain.id])
446
+ await trxMgr.query(queryStrings, [
447
+ domain.id,
448
+ releaseShelfLifeOverrideFilter ? releaseShelfLifeOverrideFilter.value : null
449
+ ])
429
450
 
430
451
  const [{ total }]: any = await trxMgr.query(
431
452
  `select count(*) as total from temp_inventory_product_group ${filterGroupTypeQuery ? filterGroupTypeQuery : ''}`
@@ -873,7 +894,8 @@ export const InventoryUtil = {
873
894
  recall: boolean = null,
874
895
  cartonId?: string,
875
896
  expirationDate?: Date,
876
- warehouseName?: string
897
+ warehouseName?: string,
898
+ outboundShelfLifeOverride?: number
877
899
  ): Promise<OrderInventory[]> {
878
900
  let strictProduct = 'false'
879
901
 
@@ -918,14 +940,15 @@ export const InventoryUtil = {
918
940
  .andWhere('"iv"."transfer_uom_value" <= 0')
919
941
  .andWhere('"iv"."product_id" = :productId')
920
942
  .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'
943
+ 'case when "iv"."expiration_date" is not null then CURRENT_DATE < "iv"."expiration_date" - (case when :outboundShelfLifeOverride::integer is not null and :outboundShelfLifeOverride::integer > 0 then :outboundShelfLifeOverride::integer when "p"."min_outbound_shelf_life" is not null then "p"."min_outbound_shelf_life" else 0 end) else true end'
922
944
  )
923
945
  .setParameters({
924
946
  domainId: domain.id,
925
947
  bizplaceId: customerBizplace.id,
926
948
  productId: product.id,
927
949
  status: INVENTORY_STATUS.STORED,
928
- locationTypes
950
+ locationTypes,
951
+ outboundShelfLifeOverride: outboundShelfLifeOverride ?? null
929
952
  })
930
953
 
931
954
  if (batchId) {