@things-factory/worksheet-base 4.3.824 → 4.3.827

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.
@@ -3,12 +3,15 @@ import { getRepository, In } from 'typeorm'
3
3
  import { ArrivalNotice, ReturnOrder } from '@things-factory/sales-base'
4
4
  import { Setting } from '@things-factory/setting-base'
5
5
  import { Domain } from '@things-factory/shell'
6
- import { Inventory, Location, LOCATION_STATUS, LOCATION_TYPE, Warehouse } from '@things-factory/warehouse-base'
6
+ import { Inventory, Location, LOCATION_STATUS, LOCATION_TYPE, Warehouse, calcVolumeInM3, buildProductDetailLabel } from '@things-factory/warehouse-base'
7
7
 
8
8
  import { WORKSHEET_STATUS, WORKSHEET_TYPE } from '../../../constants'
9
9
  import { Worksheet, WorksheetDetail } from '../../../entities'
10
10
  import { isInventoryExpiring } from '../../../utils/inventory-util'
11
11
 
12
+ // 10% headroom required when storing — accounts for practical height gaps in shelving
13
+ const HEIGHT_GAP_FACTOR = 0.9 // ONLY FOR RECOMMENDATION LEVEL 4
14
+
12
15
  export const recommendPutawayLocationResolver = {
13
16
  async recommendPutawayLocation(
14
17
  _: void,
@@ -26,7 +29,8 @@ export const recommendPutawayLocationResolver = {
26
29
  'worksheet.returnOrder',
27
30
  'targetInventory',
28
31
  'targetInventory.inventory',
29
- 'targetInventory.inventory.product'
32
+ 'targetInventory.inventory.product',
33
+ 'targetInventory.inventory.productDetail'
30
34
  ]
31
35
  }
32
36
  )
@@ -87,6 +91,10 @@ export const recommendPutawayLocationResolver = {
87
91
  recommendedLocations = await recommendLocationLevel3(domain, targetWarehouse, optCnt, inventory)
88
92
  break
89
93
 
94
+ case 'level 4':
95
+ recommendedLocations = await recommendLocationLevel4(domain, targetWarehouse, optCnt, inventory)
96
+ break
97
+
90
98
  default:
91
99
  recommendedLocations = await recommendLocationLevel1(domain, targetWarehouse, optCnt)
92
100
  break
@@ -404,7 +412,7 @@ export async function recommendLocationLevel3(
404
412
 
405
413
  // ---------------- EMPTY fallback (same type, ordered by rule) ----------------
406
414
  if (recommended.length < optCnt) {
407
- const emptyLocations = await getRepository(Location).find({
415
+ const emptyLocationsAll = await getRepository(Location).find({
408
416
  where: {
409
417
  domain,
410
418
  warehouse,
@@ -415,9 +423,107 @@ export async function recommendLocationLevel3(
415
423
  take: optCnt - recommended.length
416
424
  })
417
425
 
418
- recommended.push(...emptyLocations)
426
+ recommended.push(...emptyLocationsAll)
419
427
  }
420
428
  }
421
429
 
422
430
  return recommended.slice(0, optCnt)
423
431
  }
432
+
433
+ export async function recommendLocationLevel4(
434
+ domain: Domain,
435
+ warehouse: Warehouse,
436
+ optCnt: number,
437
+ inventory: Inventory
438
+ ): Promise<Location[]> {
439
+ const allowedTypesInOrder: LOCATION_TYPE[] = [LOCATION_TYPE.STORAGE, LOCATION_TYPE.SHELF, LOCATION_TYPE.FLOOR]
440
+
441
+ // --- Step 1: Check all EMPTY locations have volume ---
442
+ const emptyLocations: Location[] = await getRepository(Location).find({
443
+ where: {
444
+ domain,
445
+ warehouse,
446
+ status: LOCATION_STATUS.EMPTY,
447
+ type: In(allowedTypesInOrder)
448
+ }
449
+ })
450
+
451
+ const missingVolume = emptyLocations.some((loc: Location) => !loc.volume)
452
+ if (missingVolume) {
453
+ throw new Error('Please update the Location Master to include volume calculation')
454
+ }
455
+
456
+ // --- Step 2: Check product dimensions and length unit ---
457
+ const pd = inventory.productDetail
458
+ if (!pd?.lengthUnit || (pd?.volume == null && (!pd?.width || !pd?.depth || !pd?.height))) {
459
+ throw new Error(
460
+ 'Missing product dimensions or length unit. Please provide width, depth, height, and length unit for correct volume calculation'
461
+ )
462
+ }
463
+
464
+ const incomingDetail = buildProductDetailLabel(pd)
465
+ const incomingLabel = `SKU: ${inventory.product?.sku} ${incomingDetail}`.trim()
466
+ const incomingVolume: number = calcVolumeInM3(pd, incomingLabel) * (inventory.qty ?? 1)
467
+
468
+ // --- Step 3: rule-for-storing-product ---
469
+ const orderSetting: Setting | undefined = await getRepository(Setting).findOne({
470
+ where: { domain, name: 'rule-for-storing-product' }
471
+ })
472
+
473
+ let sortLocation: Record<string, 'ASC' | 'DESC'> = {}
474
+ if (orderSetting?.value) {
475
+ try {
476
+ sortLocation = JSON.parse(orderSetting.value)
477
+ } catch {
478
+ // ignore malformed setting
479
+ }
480
+ }
481
+
482
+ // --- Step 4: Check if all empty locations having same volume ---
483
+ const allSameVolume =
484
+ emptyLocations.length > 0 && emptyLocations.every((loc: Location) => loc.volume === emptyLocations[0].volume)
485
+
486
+ if (allSameVolume) {
487
+ const usableVolume = emptyLocations[0].volume * HEIGHT_GAP_FACTOR
488
+ if (incomingVolume > usableVolume) return [] // return early to not recommend any
489
+ }
490
+
491
+ const recommended: Location[] = []
492
+
493
+ for (const type of allowedTypesInOrder) {
494
+ if (recommended.length >= optCnt) break
495
+
496
+ const candidates: Location[] = emptyLocations.filter((loc: Location) => loc.type === type)
497
+ if (candidates.length === 0) continue
498
+
499
+ if (allSameVolume) {
500
+ const sortKeys = Object.keys(sortLocation)
501
+ const sorted = candidates.sort((a: Location, b: Location) => {
502
+ for (const key of sortKeys) {
503
+ const dir = sortLocation[key] === 'DESC' ? -1 : 1
504
+ if (a[key] < b[key]) return -1 * dir
505
+ if (a[key] > b[key]) return 1 * dir
506
+ }
507
+ return 0
508
+ })
509
+ recommended.push(...sorted.slice(0, optCnt - recommended.length))
510
+ if (recommended.length >= optCnt) break
511
+ } else {
512
+ // Sort by least remaining space ASC (usable = volume * 0.9, 10% height gap)
513
+ const sorted = candidates
514
+ .map((loc: Location) => {
515
+ const usableVolume = loc.volume * HEIGHT_GAP_FACTOR
516
+ const remaining = usableVolume - incomingVolume
517
+ return { loc, remaining }
518
+ })
519
+ .filter(({ remaining }) => remaining >= 0)
520
+ .sort((a, b) => a.remaining - b.remaining)
521
+ .map(({ loc }) => loc)
522
+
523
+ recommended.push(...sorted.slice(0, optCnt - recommended.length))
524
+ }
525
+ }
526
+
527
+ return recommended.slice(0, optCnt)
528
+ }
529
+