@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/dist-server/controllers/inbound/putaway-worksheet-controller.js +83 -0
- package/dist-server/controllers/inbound/putaway-worksheet-controller.js.map +1 -1
- package/dist-server/graphql/resolvers/worksheet/index.js +2 -2
- package/dist-server/graphql/resolvers/worksheet/index.js.map +1 -1
- package/dist-server/graphql/resolvers/worksheet/recommend-putaway-location.js +331 -0
- package/dist-server/graphql/resolvers/worksheet/recommend-putaway-location.js.map +1 -0
- package/package.json +6 -6
- package/server/controllers/inbound/putaway-worksheet-controller.ts +87 -0
- package/server/graphql/resolvers/worksheet/index.ts +1 -1
- package/server/graphql/resolvers/worksheet/recommend-putaway-location.ts +423 -0
- package/dist-server/graphql/resolvers/worksheet/recommend-putway-location.js +0 -157
- package/dist-server/graphql/resolvers/worksheet/recommend-putway-location.js.map +0 -1
- package/server/graphql/resolvers/worksheet/recommend-putway-location.ts +0 -212
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/worksheet-base",
|
|
3
|
-
"version": "4.3.
|
|
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.
|
|
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.
|
|
37
|
+
"@things-factory/marketplace-base": "^4.3.694",
|
|
38
38
|
"@things-factory/notification": "^4.3.689",
|
|
39
|
-
"@things-factory/sales-base": "^4.3.
|
|
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.
|
|
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": "
|
|
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-
|
|
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
|
+
}
|