@things-factory/worksheet-base 4.3.823 → 4.3.825

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.
Files changed (19) hide show
  1. package/dist-server/controllers/outbound/loading-worksheet-controller.js +7 -0
  2. package/dist-server/controllers/outbound/loading-worksheet-controller.js.map +1 -1
  3. package/dist-server/controllers/outbound/picking-worksheet-controller.js +58 -58
  4. package/dist-server/controllers/outbound/picking-worksheet-controller.js.map +1 -1
  5. package/dist-server/controllers/outbound/sorting-worksheet-controller.js +6 -6
  6. package/dist-server/controllers/outbound/sorting-worksheet-controller.js.map +1 -1
  7. package/dist-server/graphql/resolvers/worksheet/picking/complete-picking.js +0 -2
  8. package/dist-server/graphql/resolvers/worksheet/picking/complete-picking.js.map +1 -1
  9. package/dist-server/graphql/resolvers/worksheet/picking/fetch-and-assign-picking-task.js +17 -6
  10. package/dist-server/graphql/resolvers/worksheet/picking/fetch-and-assign-picking-task.js.map +1 -1
  11. package/dist-server/graphql/resolvers/worksheet/recommend-putaway-location.js +94 -4
  12. package/dist-server/graphql/resolvers/worksheet/recommend-putaway-location.js.map +1 -1
  13. package/package.json +12 -12
  14. package/server/controllers/outbound/loading-worksheet-controller.ts +9 -0
  15. package/server/controllers/outbound/picking-worksheet-controller.ts +69 -69
  16. package/server/controllers/outbound/sorting-worksheet-controller.ts +7 -7
  17. package/server/graphql/resolvers/worksheet/picking/complete-picking.ts +0 -2
  18. package/server/graphql/resolvers/worksheet/picking/fetch-and-assign-picking-task.ts +17 -6
  19. package/server/graphql/resolvers/worksheet/recommend-putaway-location.ts +110 -4
@@ -1,11 +1,11 @@
1
- import { EntityManager, Equal, getConnection, getManager, In, IsNull, Not } from 'typeorm'
1
+ import { EntityManager, Equal, getManager, In, IsNull, Not } from 'typeorm'
2
2
  import { v4 as uuidv4 } from 'uuid'
3
3
 
4
4
  import { ApplicationType, User } from '@things-factory/auth-base'
5
5
  import { Bizplace } from '@things-factory/biz-base'
6
6
  import { logger } from '@things-factory/env'
7
7
  import { generateId } from '@things-factory/id-rule-base'
8
- import { Powrup } from '@things-factory/integration-powrup'
8
+ import { notifyPowrupOrderStatus, Powrup } from '@things-factory/integration-powrup'
9
9
  import { Sellercraft, SellercraftStatus } from '@things-factory/integration-sellercraft'
10
10
  import { Product, ProductDetail } from '@things-factory/product-base'
11
11
  import {
@@ -436,6 +436,9 @@ export class PickingWorksheetController extends VasWorksheetController {
436
436
  }
437
437
 
438
438
  webhookHandler(releaseGood, releaseGood.bizplace, WebhookEventsEnum.PickingStarted)
439
+ if (releaseGood.source === ApplicationType.POWRUP) {
440
+ notifyPowrupOrderStatus(releaseGood, 'PICKING', worksheet.startedAt)
441
+ }
439
442
 
440
443
  if (newReleaseGood) {
441
444
  worksheet.releaseGood = newReleaseGood
@@ -722,6 +725,9 @@ export class PickingWorksheetController extends VasWorksheetController {
722
725
 
723
726
  for (const releaseGood of releaseGoods) {
724
727
  webhookHandler(releaseGood, releaseGood.bizplace, WebhookEventsEnum.PickingStarted)
728
+ if (releaseGood.source === ApplicationType.POWRUP) {
729
+ notifyPowrupOrderStatus(releaseGood, 'PICKING', worksheet.startedAt)
730
+ }
725
731
  }
726
732
 
727
733
  return worksheet
@@ -1108,9 +1114,6 @@ export class PickingWorksheetController extends VasWorksheetController {
1108
1114
  await this.toteScanning(toteNo, targetProduct, targetInventory, pickedQty, releaseGood, bizplace)
1109
1115
  }
1110
1116
 
1111
- // temporarily override with separate logic to handle transaction to speed up function
1112
- // await this.updatePickingTransaction(releaseGood, targetInventory, worksheetDetail, inventory, pickedQty)
1113
-
1114
1117
  const releaseQty: number = targetInventory.releaseQty
1115
1118
 
1116
1119
  targetInventory.pickedQty = (targetInventory?.pickedQty || 0) + pickedQty
@@ -1141,78 +1144,72 @@ export class PickingWorksheetController extends VasWorksheetController {
1141
1144
  .execute()
1142
1145
 
1143
1146
  if (oiUpdateResult.affected > 0 && targetInventory.pickedQty == releaseQty) {
1144
- getConnection().transaction(async (tx: EntityManager) => {
1145
- try {
1146
- //update worksheet details only when line item picking complete
1147
- await tx
1148
- .getRepository(WorksheetDetail)
1149
- .createQueryBuilder()
1150
- .update(WorksheetDetail)
1151
- .set({
1152
- status: WORKSHEET_STATUS.DONE,
1153
- updater: this.user,
1154
- updatedAt: new Date()
1155
- })
1156
- .where('id = :id', { id: worksheetDetailInfos.worksheetDetailId })
1157
- .execute()
1147
+ //update worksheet details only when line item picking complete
1148
+ await this.trxMgr
1149
+ .getRepository(WorksheetDetail)
1150
+ .createQueryBuilder()
1151
+ .update(WorksheetDetail)
1152
+ .set({
1153
+ status: WORKSHEET_STATUS.DONE,
1154
+ updater: this.user,
1155
+ updatedAt: new Date()
1156
+ })
1157
+ .where('id = :id', { id: worksheetDetailInfos.worksheetDetailId })
1158
+ .execute()
1158
1159
 
1159
- let releaseUomValue = Math.trunc((pickedUomValue / pickedQty) * releaseQty * 1000) / 1000
1160
+ let releaseUomValue = Math.trunc((pickedUomValue / pickedQty) * releaseQty * 1000) / 1000
1160
1161
 
1161
- let updateInvObj = {
1162
- qty: () => `"qty" - :deductQty::numeric`,
1163
- lockedQty: () => `GREATEST("locked_qty" - :deductQty::numeric, 0)`,
1164
- uomValue: () => `"uom_value" - :deductUomValue::numeric`,
1165
- lockedUomValue: () => `GREATEST("locked_uom_value" - :deductUomValue::numeric, 0)`,
1166
- status: () =>
1167
- `case when "qty" - :deductQty::numeric <= 0 then '${INVENTORY_STATUS.TERMINATED}' else status end`,
1168
- updater: this.user,
1169
- updatedAt: new Date()
1170
- }
1162
+ let updateInvObj = {
1163
+ qty: () => `"qty" - :deductQty::numeric`,
1164
+ lockedQty: () => `GREATEST("locked_qty" - :deductQty::numeric, 0)`,
1165
+ uomValue: () => `"uom_value" - :deductUomValue::numeric`,
1166
+ lockedUomValue: () => `GREATEST("locked_uom_value" - :deductUomValue::numeric, 0)`,
1167
+ status: () =>
1168
+ `case when "qty" - :deductQty::numeric <= 0 then '${INVENTORY_STATUS.TERMINATED}' else status end`,
1169
+ updater: this.user,
1170
+ updatedAt: new Date()
1171
+ }
1171
1172
 
1172
- const invUpdateResult = await tx
1173
- .getRepository(Inventory)
1174
- .createQueryBuilder()
1175
- .update(Inventory)
1176
- .set(updateInvObj)
1177
- .setParameter('deductQty', releaseQty)
1178
- .setParameter('deductUomValue', releaseUomValue)
1179
- .where('id = :id AND qty >= :deductQty', {
1180
- id: worksheetDetailInfos.inventoryId,
1181
- deductQty: releaseQty
1182
- })
1183
- .returning(['qty'])
1184
- .execute()
1173
+ const invUpdateResult = await this.trxMgr
1174
+ .getRepository(Inventory)
1175
+ .createQueryBuilder()
1176
+ .update(Inventory)
1177
+ .set(updateInvObj)
1178
+ .setParameter('deductQty', releaseQty)
1179
+ .setParameter('deductUomValue', releaseUomValue)
1180
+ .where('id = :id AND qty >= :deductQty', {
1181
+ id: worksheetDetailInfos.inventoryId,
1182
+ deductQty: releaseQty
1183
+ })
1184
+ .returning(['qty'])
1185
+ .execute()
1185
1186
 
1186
- if (invUpdateResult.affected === 0) {
1187
- throw new Error(`Insufficient inventory quantity to complete picking`)
1188
- }
1187
+ if (invUpdateResult.affected === 0) {
1188
+ throw new Error(`Insufficient inventory quantity to complete picking`)
1189
+ }
1189
1190
 
1190
- await generateInventoryHistory(
1191
- inventory,
1192
- releaseGood,
1193
- INVENTORY_TRANSACTION_TYPE.PICKING,
1194
- -releaseQty,
1195
- -releaseUomValue,
1196
- this.user,
1197
- tx
1198
- )
1191
+ await generateInventoryHistory(
1192
+ inventory,
1193
+ releaseGood,
1194
+ INVENTORY_TRANSACTION_TYPE.PICKING,
1195
+ -releaseQty,
1196
+ -releaseUomValue,
1197
+ this.user,
1198
+ this.trxMgr
1199
+ )
1199
1200
 
1200
- let inventoryItems: InventoryItem = await tx
1201
- .getRepository(InventoryItem)
1202
- .find({ where: { outboundOrderId: worksheetDetailInfos.releaseGoodId } })
1201
+ let inventoryItems: InventoryItem[] = await this.trxMgr
1202
+ .getRepository(InventoryItem)
1203
+ .find({ where: { outboundOrderId: worksheetDetailInfos.releaseGoodId } })
1203
1204
 
1204
- if (inventoryItems.length > 0) {
1205
- inventoryItems.forEach((itm: InventoryItem) => {
1206
- itm.status = INVENTORY_STATUS.PICKED
1207
- itm.updater = this.user
1208
- })
1205
+ if (inventoryItems.length > 0) {
1206
+ inventoryItems.forEach((itm: InventoryItem) => {
1207
+ itm.status = INVENTORY_STATUS.PICKED
1208
+ itm.updater = this.user
1209
+ })
1209
1210
 
1210
- await tx.getRepository(InventoryItem).save(inventoryItems)
1211
- }
1212
- } catch (error) {
1213
- throw error
1214
- }
1215
- })
1211
+ await this.trxMgr.getRepository(InventoryItem).save(inventoryItems)
1212
+ }
1216
1213
  }
1217
1214
 
1218
1215
  let worksheetDetail = await this.trxMgr
@@ -3128,6 +3125,9 @@ export class PickingWorksheetController extends VasWorksheetController {
3128
3125
  }
3129
3126
 
3130
3127
  webhookHandler(releaseGood, releaseGood.bizplace, WebhookEventsEnum.PickingStarted)
3128
+ if (releaseGood.source === ApplicationType.POWRUP) {
3129
+ notifyPowrupOrderStatus(releaseGood, 'PICKING', worksheet.startedAt)
3130
+ }
3131
3131
 
3132
3132
  worksheet.releaseGood = newReleaseGood ? newReleaseGood : releaseGood
3133
3133
  updatedWs.push(worksheet)
@@ -1,6 +1,8 @@
1
1
  import { In, IsNull } from 'typeorm'
2
2
  import { v4 as uuidv4 } from 'uuid'
3
+ import { ApplicationType } from '@things-factory/auth-base'
3
4
  import { webhookHandler, WebhookEventsEnum } from '@things-factory/integration-base'
5
+ import { notifyPowrupOrderStatus } from '@things-factory/integration-powrup'
4
6
  import { Product, ProductBarcode, ProductDetail } from '@things-factory/product-base'
5
7
  import {
6
8
  ORDER_INVENTORY_STATUS,
@@ -143,6 +145,11 @@ export class SortingWorksheetController extends VasWorksheetController {
143
145
  })
144
146
 
145
147
  await this.trxMgr.getRepository(WorksheetDetail).insert(loadingWorksheetDetails)
148
+
149
+ webhookHandler(releaseGood, releaseGood.bizplace, WebhookEventsEnum.LoadingStarted)
150
+ if (releaseGood.source === ApplicationType.POWRUP) {
151
+ notifyPowrupOrderStatus(releaseGood, 'LOADING')
152
+ }
146
153
  }
147
154
 
148
155
  return savedSortingWorksheet
@@ -700,13 +707,6 @@ export class SortingWorksheetController extends VasWorksheetController {
700
707
  worksheet.updater = this.user
701
708
  worksheet = await this.trxMgr.getRepository(Worksheet).save(worksheet)
702
709
 
703
- const releaseGoods: ReleaseGood[] = await this.trxMgr
704
- .getRepository(ReleaseGood)
705
- .find({ where: { id: In(releaseGoodIds) }, relations: ['domain', 'bizplace'] })
706
-
707
- for (let releaseGood of releaseGoods) {
708
- webhookHandler(releaseGood, releaseGood.bizplace, WebhookEventsEnum.LoadingStarted)
709
- }
710
710
 
711
711
  return worksheet
712
712
  }
@@ -1,5 +1,4 @@
1
1
  import { EntityManager, getManager, In } from 'typeorm'
2
- import { webhookHandler, WebhookEventsEnum } from '@things-factory/integration-base'
3
2
  import { ApplicationType, User } from '@things-factory/auth-base'
4
3
  import { Bizplace, ContactPoint, getMyBizplace } from '@things-factory/biz-base'
5
4
  import { logger } from '@things-factory/env'
@@ -411,7 +410,6 @@ export async function completePicking(
411
410
 
412
411
  const loadingWorksheetDetails: WorksheetDetail[] = loadingWorksheet.worksheetDetails
413
412
  await loadingWSCtrl.activateLoading(loadingWorksheet.name, loadingWorksheetDetails)
414
- webhookHandler(releaseGood, releaseGood.bizplace, WebhookEventsEnum.LoadingStarted)
415
413
  } else {
416
414
  const loadingWSCtrl: LoadingWorksheetController = new LoadingWorksheetController(tx, domain, user)
417
415
  let loadingWorksheet: Worksheet = await loadingWSCtrl.updateLoadingWorksheet(
@@ -56,6 +56,15 @@ export const fetchAndAssignPickingTaskResolver = {
56
56
 
57
57
  if (!worksheet) {
58
58
  // update and return updated worksheet id
59
+ // FOR UPDATE OF ws SKIP LOCKED: under concurrent clicks, two callers
60
+ // used to SELECT the same row and the second UPDATE would re-evaluate,
61
+ // find assignee_id no longer NULL, update zero rows, and the user
62
+ // would see a confusing "no suitable worksheet found" error even
63
+ // though other unassigned rows existed. SKIP LOCKED makes concurrent
64
+ // callers pick different rows. `OF ws` scopes the row lock to the
65
+ // worksheets table only (not the joined release_goods rows). Inner
66
+ // transaction is independent so the row lock is released as soon as
67
+ // the UPDATE commits.
59
68
  let updatedWorksheets = await getConnection().transaction(async (tx2: EntityManager) => {
60
69
  let x = await tx2.getRepository(Worksheet).query(
61
70
  `
@@ -65,23 +74,25 @@ export const fetchAndAssignPickingTaskResolver = {
65
74
  FROM (
66
75
  SELECT ws.id, ws.name, ws.status, ws.release_good_id FROM worksheets ws
67
76
  INNER JOIN release_goods rg ON rg.id = ws.release_good_id
68
- WHERE
69
- ws.domain_id = $1 AND
77
+ WHERE
78
+ ws.domain_id = $1 AND
70
79
  ws.type = 'PICKING'
71
- AND ws.status IN ('EXECUTING', 'DEACTIVATED')
80
+ AND ws.status IN ('EXECUTING', 'DEACTIVATED')
72
81
  AND ws.assignee_id IS NULL
73
82
  /*
74
83
  (condition is commented as current status in oi not in use)
75
84
  AND NOT EXISTS (
76
85
  SELECT count(id) AS "totalPS"
77
- FROM order_inventories oi
86
+ FROM order_inventories oi
78
87
  WHERE oi.release_good_id = ws.release_good_id AND
79
88
  oi.status = 'PENDING_SPLIT'
80
- GROUP BY oi.release_good_id
89
+ GROUP BY oi.release_good_id
81
90
  HAVING count(id) > 0
82
91
  )
83
92
  */
84
- ORDER BY rg.release_date, rg.created_at LIMIT 1
93
+ ORDER BY rg.release_date, rg.created_at
94
+ LIMIT 1
95
+ FOR UPDATE OF ws SKIP LOCKED
85
96
  ) src where src.id = ws.id
86
97
  RETURNING ws.id;
87
98
  `,
@@ -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
+