@things-factory/worksheet-base 4.3.693 → 4.3.694

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/worksheet-base",
3
- "version": "4.3.693",
3
+ "version": "4.3.694",
4
4
  "main": "dist-server/index.js",
5
5
  "browser": "client/index.js",
6
6
  "things-factory": true,
@@ -27,24 +27,24 @@
27
27
  "@things-factory/biz-base": "^4.3.689",
28
28
  "@things-factory/document-template-base": "^4.3.689",
29
29
  "@things-factory/id-rule-base": "^4.3.689",
30
- "@things-factory/integration-accounting": "^4.3.689",
30
+ "@things-factory/integration-accounting": "^4.3.694",
31
31
  "@things-factory/integration-base": "^4.3.689",
32
32
  "@things-factory/integration-lmd": "^4.3.689",
33
33
  "@things-factory/integration-marketplace": "^4.3.689",
34
34
  "@things-factory/integration-powrup": "^4.3.689",
35
35
  "@things-factory/integration-sellercraft": "^4.3.689",
36
36
  "@things-factory/integration-sftp": "^4.3.689",
37
- "@things-factory/marketplace-base": "^4.3.689",
37
+ "@things-factory/marketplace-base": "^4.3.694",
38
38
  "@things-factory/notification": "^4.3.689",
39
- "@things-factory/sales-base": "^4.3.693",
39
+ "@things-factory/sales-base": "^4.3.694",
40
40
  "@things-factory/setting-base": "^4.3.689",
41
41
  "@things-factory/shell": "^4.3.689",
42
42
  "@things-factory/transport-base": "^4.3.689",
43
- "@things-factory/warehouse-base": "^4.3.691",
43
+ "@things-factory/warehouse-base": "^4.3.694",
44
44
  "@things-factory/worksheet-ui": "^4.3.689",
45
45
  "jspdf": "2.5.1",
46
46
  "puppeteer": "21.0.3",
47
47
  "uuid": "^9.0.0"
48
48
  },
49
- "gitHead": "e8372d502e3b7e99b9918eae3ed57093a50660bf"
49
+ "gitHead": "a1bf668bd52a6b9f12b719ca1467beb423270400"
50
50
  }
@@ -28,6 +28,7 @@ import { Worksheet, WorksheetDetail } from '../../entities'
28
28
  import { webhookHandler, WebhookEventsEnum } from '@things-factory/integration-base'
29
29
  import { switchLocationStatus } from '../../utils'
30
30
  import { VasWorksheetController } from '../vas/vas-worksheet-controller'
31
+ import { isInventoryExpiring } from '../../utils/inventory-util'
31
32
 
32
33
  export class PutawayWorksheetController extends VasWorksheetController {
33
34
  async generatePutawayWorksheet(arrivalNoticeId: string, inventories: Inventory[]): Promise<Worksheet> {
@@ -122,6 +123,89 @@ export class PutawayWorksheetController extends VasWorksheetController {
122
123
  }
123
124
  }
124
125
 
126
+ private async validateLocationLevel3(inventory: Inventory, location: Location): Promise<void> {
127
+ // Only enforce for level 3
128
+ const levelSetting: Setting = await this.trxMgr.getRepository(Setting).findOne({
129
+ where: { domain: this.domain, name: 'location-recommendation-level' }
130
+ })
131
+ if (levelSetting?.value !== 'level 3') return
132
+
133
+ // Expiry enforcement: quarantine only
134
+ const expiring = await isInventoryExpiring(inventory)
135
+ if (expiring) {
136
+ if (location.type !== LOCATION_TYPE.QUARANTINE) {
137
+ throw new Error('Expiring inventory must be stored in QUARANTINE location')
138
+ }
139
+ return
140
+ }
141
+
142
+ // Allowed types (ranking is used for recommendation; validation enforces allowed set)
143
+ const allowedTypes = [LOCATION_TYPE.STORAGE, LOCATION_TYPE.SHELF, LOCATION_TYPE.FLOOR]
144
+ if (!allowedTypes.includes(location.type as any)) {
145
+ throw new Error('Scanned location type is not allowed for putaway (allowed: STORAGE, SHELF, FLOOR)')
146
+ }
147
+
148
+ // Stacking limits
149
+ const stackingSetting: Setting = await this.trxMgr.getRepository(Setting).findOne({
150
+ where: { domain: this.domain, name: 'stacking-option-limit' }
151
+ })
152
+ let minLimit = 1
153
+ let maxLimit = 1
154
+ if (stackingSetting?.value) {
155
+ try {
156
+ const parsed = JSON.parse(stackingSetting.value)
157
+ minLimit = Number(parsed.Min ?? parsed.min ?? minLimit) || minLimit
158
+ maxLimit = Number(parsed.Max ?? parsed.max ?? maxLimit) || maxLimit
159
+ } catch (e) {}
160
+ }
161
+
162
+ // Ensure product is loaded to check allowStackingOption
163
+ if (!inventory.product) {
164
+ inventory = await this.trxMgr.getRepository(Inventory).findOne({
165
+ where: { id: inventory.id },
166
+ relations: ['product']
167
+ })
168
+ }
169
+ const stackingLimit = inventory.product?.allowStackingOption ? maxLimit : minLimit
170
+
171
+ // Count inventories in the scanned location
172
+ const totalCount = await this.trxMgr
173
+ .getRepository(Inventory)
174
+ .createQueryBuilder('inv')
175
+ .leftJoin('inv.location', 'loc')
176
+ .where('loc.id = :locId', { locId: location.id })
177
+ .andWhere('inv.status = :status', { status: INVENTORY_STATUS.STORED })
178
+ .getCount()
179
+
180
+ if (totalCount >= stackingLimit) {
181
+ throw new Error('Scanned location reached stacking limit for this product')
182
+ }
183
+
184
+ if (totalCount > 0) {
185
+ // Homogeneity check: ensure all inventories match product, batch, expiration
186
+ const params: any = { pid: inventory.product.id }
187
+ const batchCond = inventory.batchId ? 'inv.batchId = :batchId' : 'inv.batchId IS NULL'
188
+ if (inventory.batchId) params.batchId = inventory.batchId
189
+ const expCond = inventory.expirationDate ? 'inv.expirationDate = :exp' : 'inv.expirationDate IS NULL'
190
+ if (inventory.expirationDate) params.exp = inventory.expirationDate
191
+
192
+ const matchCount = await this.trxMgr
193
+ .getRepository(Inventory)
194
+ .createQueryBuilder('inv')
195
+ .leftJoin('inv.product', 'product')
196
+ .leftJoin('inv.location', 'loc')
197
+ .where('loc.id = :locId', { locId: location.id })
198
+ .andWhere('inv.status = :status', { status: INVENTORY_STATUS.STORED })
199
+ .andWhere('product.id = :pid', params)
200
+ .andWhere(batchCond, params)
201
+ .andWhere(expCond, params)
202
+ .getCount()
203
+
204
+ if (matchCount !== totalCount) {
205
+ throw new Error('Scanned location contains different SKU/batch/expiry; not allowed for level 3')
206
+ }
207
+ }
208
+ }
125
209
  async activatePutaway(worksheetNo: string, putawayWorksheetDetails: Partial<WorksheetDetail>[]): Promise<Worksheet> {
126
210
  let worksheet: Worksheet = await this.findActivatableWorksheet(worksheetNo, WORKSHEET_TYPE.PUTAWAY, [
127
211
  'arrivalNotice',
@@ -274,6 +358,7 @@ export class PutawayWorksheetController extends VasWorksheetController {
274
358
  relations: ['warehouse']
275
359
  })
276
360
  if (!location) throw new Error(this.ERROR_MSG.FIND.NO_RESULT(locationName))
361
+ await this.validateLocationLevel3(inventory, location)
277
362
  const warehouse: Warehouse = location.warehouse
278
363
  const zone: string = location.zone
279
364
 
@@ -332,6 +417,7 @@ export class PutawayWorksheetController extends VasWorksheetController {
332
417
  })
333
418
 
334
419
  if (!location) throw new Error(this.ERROR_MSG.FIND.NO_RESULT(locationName))
420
+ await this.validateLocationLevel3(inventory, location)
335
421
  const warehouse: Warehouse = location.warehouse
336
422
  const zone: string = warehouse.zone
337
423
 
@@ -385,6 +471,7 @@ export class PutawayWorksheetController extends VasWorksheetController {
385
471
  relations: ['warehouse']
386
472
  })
387
473
  if (!location) throw new Error(this.ERROR_MSG.FIND.NO_RESULT(locationName))
474
+ await this.validateLocationLevel3(inventory, location)
388
475
  const warehouse: Warehouse = location.warehouse
389
476
  const zone: string = warehouse.zone
390
477
 
@@ -41,7 +41,7 @@ import { putawayReplenishmentWorksheetResolver } from './putaway-replenishment-w
41
41
  import { Mutations as PutawayReturnMutations } from './putaway-return'
42
42
  import { putawayReturningWorksheetResolver } from './putaway-returning-worksheet'
43
43
  import { putawayWorksheetResolver } from './putaway-worksheet'
44
- import { recommendPutawayLocationResolver } from './recommend-putway-location'
44
+ import { recommendPutawayLocationResolver } from './recommend-putaway-location'
45
45
  import { refreshActiveWorksheetPickingViews } from './refresh-active-worksheet-picking-views'
46
46
  import { rejectCancellationReleaseOrder } from './reject-cancellation-release-order'
47
47
  import { replacePickingPalletsResolver } from './replace-picking-pallets'
@@ -0,0 +1,423 @@
1
+ import { getRepository, In } from 'typeorm'
2
+
3
+ import { ArrivalNotice, ReturnOrder } from '@things-factory/sales-base'
4
+ import { Setting } from '@things-factory/setting-base'
5
+ import { Domain } from '@things-factory/shell'
6
+ import { Inventory, Location, LOCATION_STATUS, LOCATION_TYPE, Warehouse } from '@things-factory/warehouse-base'
7
+
8
+ import { WORKSHEET_STATUS, WORKSHEET_TYPE } from '../../../constants'
9
+ import { Worksheet, WorksheetDetail } from '../../../entities'
10
+ import { isInventoryExpiring } from '../../../utils/inventory-util'
11
+
12
+ export const recommendPutawayLocationResolver = {
13
+ async recommendPutawayLocation(
14
+ _: void,
15
+ { worksheetDetailName, optCnt = 1 }: { worksheetDetailName: string; optCnt: number },
16
+ context: any
17
+ ): Promise<Location[]> {
18
+ const domain: Domain = context.state.domain
19
+
20
+ const worksheetDetail: WorksheetDetail = await getRepository(WorksheetDetail).findOne(
21
+ { domain, name: worksheetDetailName },
22
+ {
23
+ relations: [
24
+ 'worksheet',
25
+ 'worksheet.arrivalNotice',
26
+ 'worksheet.returnOrder',
27
+ 'targetInventory',
28
+ 'targetInventory.inventory',
29
+ 'targetInventory.inventory.product'
30
+ ]
31
+ }
32
+ )
33
+
34
+ const inventory: Inventory = worksheetDetail.targetInventory.inventory
35
+ if (worksheetDetail.status !== WORKSHEET_STATUS.EXECUTING) throw new Error('Current target is not processing')
36
+
37
+ const arrivalNotice: ArrivalNotice = worksheetDetail.worksheet.arrivalNotice
38
+ const returnOrder: ReturnOrder = worksheetDetail.worksheet.returnOrder
39
+
40
+ let unloadingWS: Worksheet
41
+ if (arrivalNotice) {
42
+ unloadingWS = await getRepository(Worksheet).findOne({
43
+ where: { domain, arrivalNotice, type: WORKSHEET_TYPE.UNLOADING },
44
+ relations: ['bufferLocation', 'bufferLocation.warehouse']
45
+ })
46
+ } else if (returnOrder) {
47
+ unloadingWS = await getRepository(Worksheet).findOne({
48
+ where: { domain, returnOrder, type: WORKSHEET_TYPE.UNLOADING_RETURN },
49
+ relations: ['bufferLocation', 'bufferLocation.warehouse']
50
+ })
51
+ }
52
+
53
+ const targetWarehouse: Warehouse = unloadingWS.bufferLocation.warehouse
54
+ const sortingLevelSetting: Setting = await getRepository(Setting).findOne({
55
+ where: { domain, name: 'location-recommendation-level' }
56
+ })
57
+ const sortingLevel = sortingLevelSetting?.value
58
+
59
+ const isExpiring: boolean = await isInventoryExpiring(inventory)
60
+
61
+ let recommendedLocations: Location[] = []
62
+ if (isExpiring) {
63
+ switch (sortingLevel) {
64
+ case 'level 1':
65
+ case 'level 2':
66
+ recommendedLocations = await recommendQuarantineLocationLevel1(domain, targetWarehouse, optCnt)
67
+ break
68
+
69
+ default:
70
+ recommendedLocations = await recommendQuarantineLocationLevel1(domain, targetWarehouse, optCnt)
71
+ break
72
+ }
73
+ }
74
+
75
+ if (recommendedLocations.length > 0) return recommendedLocations
76
+
77
+ switch (sortingLevel) {
78
+ case 'level 1':
79
+ recommendedLocations = await recommendLocationLevel1(domain, targetWarehouse, optCnt)
80
+ break
81
+
82
+ case 'level 2':
83
+ recommendedLocations = await recommendLocationLevel2(domain, targetWarehouse, optCnt, inventory.product.id)
84
+ break
85
+
86
+ case 'level 3':
87
+ recommendedLocations = await recommendLocationLevel3(domain, targetWarehouse, optCnt, inventory)
88
+ break
89
+
90
+ default:
91
+ recommendedLocations = await recommendLocationLevel1(domain, targetWarehouse, optCnt)
92
+ break
93
+ }
94
+
95
+ return recommendedLocations
96
+ }
97
+ }
98
+
99
+ export async function recommendLocationLevel1(
100
+ domain: Domain,
101
+ warehouse: Warehouse,
102
+ optCnt: number
103
+ ): Promise<Location[]> {
104
+ return await getEmptyLocations(domain, warehouse, optCnt, [LOCATION_TYPE.SHELF, LOCATION_TYPE.FLOOR])
105
+ }
106
+
107
+ export async function recommendLocationLevel2(
108
+ domain: Domain,
109
+ warehouse: Warehouse,
110
+ optCnt: number,
111
+ productId: string
112
+ ): Promise<Location[]> {
113
+ return await getLevel2Location(
114
+ domain,
115
+ warehouse,
116
+ optCnt,
117
+ [LOCATION_TYPE.STORAGE, LOCATION_TYPE.SHELF, LOCATION_TYPE.FLOOR],
118
+ productId
119
+ )
120
+ }
121
+
122
+ export async function recommendQuarantineLocationLevel1(
123
+ domain: Domain,
124
+ warehouse: Warehouse,
125
+ optCnt: number
126
+ ): Promise<Location[]> {
127
+ return await getEmptyLocations(domain, warehouse, optCnt, [LOCATION_TYPE.QUARANTINE])
128
+ }
129
+
130
+ export async function getEmptyLocations(
131
+ domain: Domain,
132
+ warehouse: Warehouse,
133
+ optCnt: number,
134
+ allowedTypes: LOCATION_TYPE[]
135
+ ): Promise<Location[]> {
136
+ const orderSetting: Setting = await getRepository(Setting).findOne({
137
+ where: { domain, name: 'rule-for-storing-product' }
138
+ })
139
+ let order = {}
140
+ if (orderSetting?.value) order = JSON.parse(orderSetting.value)
141
+
142
+ return await getRepository(Location).find({
143
+ where: {
144
+ domain,
145
+ warehouse,
146
+ status: LOCATION_STATUS.EMPTY,
147
+ type: In(allowedTypes)
148
+ },
149
+ order,
150
+ take: optCnt
151
+ })
152
+ }
153
+
154
+ export async function getLevel2Location(
155
+ domain: Domain,
156
+ warehouse: Warehouse,
157
+ optCnt: number,
158
+ allowedTypes: LOCATION_TYPE[],
159
+ productId: string
160
+ ): Promise<Location[]> {
161
+ let sortLocation: Record<string, 'ASC' | 'DESC'> = {}
162
+ const recommended: Location[] = []
163
+
164
+ // -------- rule-for-storing-product (ordering) ----------
165
+ const orderSetting: Setting | undefined = await getRepository(Setting).findOne({
166
+ where: { domain, name: 'rule-for-storing-product' }
167
+ })
168
+
169
+ if (orderSetting?.value) {
170
+ try {
171
+ // e.g. {"zone":"ASC","row":"ASC","column":"ASC","shelf":"ASC"}
172
+ sortLocation = JSON.parse(orderSetting.value)
173
+ } catch {
174
+ // ignore malformed setting, fallback to no extra ordering
175
+ }
176
+ }
177
+
178
+ // -------- main logic per allowed type ----------
179
+ for (const type of allowedTypes) {
180
+ if (recommended.length >= optCnt) break
181
+
182
+ // 1) OCCUPIED locations that already store this product,
183
+ // ordered by *product qty per location* ASC (minimal first).
184
+ //
185
+ // product_qty := SUM(inv.qty) WHERE inv.product = :productId AND inv.status='STORED'
186
+ const qbLoc = await getRepository(Location)
187
+ .createQueryBuilder('location')
188
+ .select('location.id', 'id')
189
+ .addSelect(
190
+ `COALESCE(SUM(CASE WHEN inv.product = :productId AND inv.status = :stored THEN inv.qty ELSE 0 END), 0)`,
191
+ 'product_qty'
192
+ )
193
+ .leftJoin(Inventory, 'inv', 'inv.location = location.id AND inv.domain = :domain', { domain: domain.id })
194
+ .where('location.domain = :domain', { domain: domain.id })
195
+ .andWhere('location.warehouse = :warehouse', { warehouse: warehouse.id })
196
+ .andWhere('location.status = :status', { status: LOCATION_STATUS.OCCUPIED })
197
+ .andWhere('location.type = :type', { type })
198
+ .groupBy('location.id')
199
+ // keep only locations where this product exists
200
+ .having(
201
+ `COALESCE(SUM(CASE WHEN inv.product = :productId AND inv.status = :stored THEN inv.qty ELSE 0 END), 0) > 0`,
202
+ { productId, stored: 'STORED' }
203
+ )
204
+ .orderBy('product_qty', 'ASC') // minimal qty first
205
+
206
+ // secondary ordering: rule-for-storing-product
207
+ Object.entries(sortLocation).forEach(([key, value]) => {
208
+ qbLoc.addOrderBy(`location.${key}`, value as 'ASC' | 'DESC')
209
+ })
210
+
211
+ qbLoc.limit(optCnt - recommended.length)
212
+
213
+ const rows = await qbLoc.getRawMany<{ id: string; product_qty: string }>()
214
+ if (rows.length > 0) {
215
+ const ids = rows.map(r => r.id)
216
+
217
+ const locEntities = await getRepository(Location).find({
218
+ where: { id: In(ids) as any }
219
+ })
220
+
221
+ const byId: Map<string, Location> = new Map(locEntities.map((l: Location) => [l.id, l as Location]))
222
+ for (const id of ids) {
223
+ const loc = byId.get(id)
224
+ if (loc) {
225
+ recommended.push(loc)
226
+ if (recommended.length >= optCnt) break
227
+ }
228
+ }
229
+ }
230
+
231
+ // 2) If still not enough, suggest EMPTY locations for that type,
232
+ // ordered purely by rule-for-storing-product.
233
+ if (recommended.length < optCnt) {
234
+ const empties = await getRepository(Location).find({
235
+ where: {
236
+ domain,
237
+ warehouse,
238
+ status: LOCATION_STATUS.EMPTY,
239
+ type
240
+ } as any,
241
+ order: sortLocation,
242
+ take: optCnt - recommended.length
243
+ })
244
+
245
+ recommended.push(...empties)
246
+ }
247
+ }
248
+
249
+ // de-duplicate in case of weird overlaps & hard-limit to optCnt
250
+ const seen = new Set<string>()
251
+ const unique = recommended.filter(loc => {
252
+ if (seen.has(loc.id)) return false
253
+ seen.add(loc.id)
254
+ return true
255
+ })
256
+
257
+ return unique.slice(0, optCnt)
258
+ }
259
+
260
+ export async function recommendLocationLevel3(
261
+ domain: Domain,
262
+ warehouse: Warehouse,
263
+ optCnt: number,
264
+ inventory: Inventory
265
+ ): Promise<Location[]> {
266
+ // -------- order (rule-for-storing-product) ----------
267
+ let sortLocation: Record<string, 'ASC' | 'DESC'> = {}
268
+ const orderSetting: Setting | undefined = await getRepository(Setting).findOne({
269
+ where: { domain, name: 'rule-for-storing-product' }
270
+ })
271
+
272
+ if (orderSetting?.value) {
273
+ try {
274
+ const parsed = JSON.parse(orderSetting.value)
275
+ // Expecting something like: {"zone":"ASC","row":"ASC","column":"ASC","shelf":"ASC"}
276
+ sortLocation = parsed
277
+ } catch {
278
+ // ignore malformed order setting
279
+ }
280
+ }
281
+
282
+ // -------- stacking limit (Min/Max depending on allowStackingOption) ----------
283
+ const stackingSetting: Setting | undefined = await getRepository(Setting).findOne({
284
+ where: { domain, name: 'stacking-option-limit' }
285
+ })
286
+
287
+ let minLimit = 1
288
+ let maxLimit = 1
289
+ if (stackingSetting?.value) {
290
+ const parsed = (() => {
291
+ try {
292
+ return JSON.parse(stackingSetting.value)
293
+ } catch (e) {
294
+ throw new Error('Invalid stacking-option-limit setting: must be valid JSON')
295
+ }
296
+ })()
297
+
298
+ const parsedMin = Number(parsed.Min ?? parsed.min)
299
+ const parsedMax = Number(parsed.Max ?? parsed.max)
300
+
301
+ if (Number.isNaN(parsedMin) || Number.isNaN(parsedMax)) {
302
+ throw new Error('Invalid stacking-option-limit setting: limits must be numeric')
303
+ }
304
+
305
+ if (parsedMin > parsedMax) {
306
+ throw new Error('Kindly ensure your min limit is lower than your max limit')
307
+ }
308
+
309
+ minLimit = parsedMin
310
+ maxLimit = parsedMax
311
+ }
312
+
313
+ const stackingLimit = inventory.product?.allowStackingOption ? maxLimit : minLimit
314
+
315
+ // -------- matching keys (product/batch/expiry) ----------
316
+ const productId = inventory.product?.id
317
+ if (!productId) return []
318
+
319
+ // Treat both NULL and literal '-' as "no batch" if that is how your data behaves
320
+ const normalizeBatch = (v: string | null | undefined) => (v === undefined || v === null || v === '' ? null : v)
321
+
322
+ const batchIdParam = normalizeBatch(inventory.batchId)
323
+ const expDateParam = inventory.expirationDate ?? null
324
+
325
+ const allowedTypesInOrder: LOCATION_TYPE[] = [LOCATION_TYPE.STORAGE, LOCATION_TYPE.SHELF, LOCATION_TYPE.FLOOR]
326
+
327
+ const recommended: Location[] = []
328
+
329
+ for (const type of allowedTypesInOrder) {
330
+ if (recommended.length >= optCnt) break
331
+
332
+ // ---------------- OCCUPIED, HOMOGENEOUS, UNDER CAPACITY ----------------
333
+ // Single join to inventories, then conditional aggregates:
334
+ // total_cnt = SUM(inv.id is not null)
335
+ // match_cnt = SUM(inv matches product/batch/expiry)
336
+ const matchPreds: string[] = [
337
+ 'inv.status = :stored',
338
+ 'inv.product = :productId',
339
+ batchIdParam == null ? 'inv.batchId IS NULL' : 'inv.batchId = :batchId',
340
+ expDateParam == null ? 'inv.expirationDate IS NULL' : 'inv.expirationDate = :expDate'
341
+ ]
342
+
343
+ const matchParams: any = {
344
+ stored: 'STORED',
345
+ productId
346
+ }
347
+ if (batchIdParam != null) matchParams.batchId = batchIdParam
348
+ if (expDateParam != null) matchParams.expDate = expDateParam
349
+
350
+ const qbLoc = await getRepository(Location)
351
+ .createQueryBuilder('location')
352
+ .select('location.id', 'id')
353
+ .leftJoin(Inventory, 'inv', 'inv.location = location.id AND inv.status = :stored', { stored: 'STORED' })
354
+ .where('location.domain = :domain', { domain: domain.id })
355
+ .andWhere('location.warehouse = :warehouse', { warehouse: warehouse.id })
356
+ .andWhere('location.status = :status', { status: LOCATION_STATUS.OCCUPIED })
357
+ .andWhere('location.type = :type', { type })
358
+
359
+ .groupBy('location.id')
360
+ // homogeneous: total_cnt == match_cnt
361
+ .having(
362
+ `
363
+ SUM(CASE WHEN inv.id IS NOT NULL THEN 1 ELSE 0 END)
364
+ =
365
+ SUM(CASE WHEN ${matchPreds.join(' AND ')} THEN 1 ELSE 0 END)
366
+ `,
367
+ matchParams
368
+ )
369
+ // under capacity
370
+ .andHaving(
371
+ `
372
+ SUM(CASE WHEN inv.id IS NOT NULL THEN 1 ELSE 0 END) < :stackLimit
373
+ `,
374
+ { stackLimit: stackingLimit }
375
+ )
376
+ // prefer topping up partially-filled first
377
+ .orderBy('SUM(CASE WHEN inv.id IS NOT NULL THEN 1 ELSE 0 END)', 'DESC')
378
+
379
+ // secondary sort per rule-for-storing-product
380
+ Object.entries(sortLocation).forEach(([key, value]) => {
381
+ qbLoc.addOrderBy(`location.${key}`, value as 'ASC' | 'DESC')
382
+ })
383
+
384
+ // Only fetch as many as we still need
385
+ qbLoc.limit(optCnt - recommended.length)
386
+
387
+ const locIdsRaw = await qbLoc.getRawMany()
388
+ const occupiedLocationIds: string[] = locIdsRaw.map(r => r.id)
389
+
390
+ if (occupiedLocationIds.length > 0) {
391
+ const occupiedLocations = await getRepository(Location).find({
392
+ where: { id: In(occupiedLocationIds) }
393
+ })
394
+ // keep the order from occupiedLocationIds
395
+ const byId = new Map(occupiedLocations.map((l: Location) => [l.id, l]))
396
+ for (const id of occupiedLocationIds) {
397
+ const loc = byId.get(id)
398
+ if (loc) {
399
+ recommended.push(loc)
400
+ if (recommended.length >= optCnt) break
401
+ }
402
+ }
403
+ }
404
+
405
+ // ---------------- EMPTY fallback (same type, ordered by rule) ----------------
406
+ if (recommended.length < optCnt) {
407
+ const emptyLocations = await getRepository(Location).find({
408
+ where: {
409
+ domain,
410
+ warehouse,
411
+ status: LOCATION_STATUS.EMPTY,
412
+ type
413
+ },
414
+ order: sortLocation,
415
+ take: optCnt - recommended.length
416
+ })
417
+
418
+ recommended.push(...emptyLocations)
419
+ }
420
+ }
421
+
422
+ return recommended.slice(0, optCnt)
423
+ }