@things-factory/operato-hub 4.3.697 → 4.3.698

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.
@@ -1,4 +1,3 @@
1
- import _ from 'lodash'
2
1
  import { EntityManager, SelectQueryBuilder, getConnection } from 'typeorm'
3
2
 
4
3
  import { restfulApiRouter as router } from '@things-factory/api'
@@ -9,8 +8,9 @@ import {
9
8
  InventoryChange,
10
9
  submitInventoryChangesService
11
10
  } from '@things-factory/warehouse-base'
12
- import { ProductDetail } from '@things-factory/product-base'
13
- import { getCompanyBizplace } from '@things-factory/biz-base'
11
+ import { Product, ProductDetail } from '@things-factory/product-base'
12
+ import { Bizplace, getCompanyBizplace } from '@things-factory/biz-base'
13
+ import { Setting } from '@things-factory/setting-base'
14
14
 
15
15
  import { businessMiddleware, loggingMiddleware, validationMiddleware } from '../middlewares'
16
16
  import { ApiError, ApiErrorHandler, throwInternalServerError } from '../utils/error-util'
@@ -37,10 +37,40 @@ type TargetSelector = {
37
37
  }
38
38
 
39
39
  type AdjustmentInput = {
40
- target: TargetSelector
41
- changes: Record<string, any>
40
+ target?: TargetSelector
41
+ changes?: Record<string, any>
42
+ }
43
+
44
+ type CreateInput = {
45
+ bizplaceId?: string
46
+ locationId?: string
47
+ locationName?: string
48
+ sku?: string
49
+ refCode?: string
50
+ productName?: string
51
+ packingType?: string
52
+ packingSize?: number
53
+ uom?: string
54
+ uomValue?: number
55
+ qty?: number
56
+ unitCost?: number
57
+ batchId?: string
58
+ batchIdRef?: string
59
+ cartonId?: string
60
+ expirationDate?: string
61
+ manufactureDate?: string
62
+ serialNumbers?: string | string[]
63
+ remark?: string
64
+ adjustmentCode?: string
65
+ adjustmentNote?: string
66
+ adjustment_code?: string
67
+ adjustment_note?: string
42
68
  }
43
69
 
70
+ type NormalizedEntry =
71
+ | { kind: 'create'; payload: CreateInput; source: string; index: number }
72
+ | { kind: 'adjust'; payload: AdjustmentInput; source: string; index: number }
73
+
44
74
  router.post(
45
75
  '/v1/warehouse/add-inventory-adjustment',
46
76
  businessMiddleware,
@@ -51,95 +81,230 @@ router.post(
51
81
  const { domain, user } = context.state
52
82
  const payload = context.request.body?.data
53
83
 
54
- if (!payload) throw new ApiError('E01', 'data')
55
-
56
- // Accept either { adjustments: [...] } or a single adjustment object for convenience
57
- const adjustments: AdjustmentInput[] = Array.isArray(payload?.adjustments)
58
- ? payload.adjustments
59
- : Array.isArray(payload)
60
- ? payload
61
- : payload?.target && payload?.changes
62
- ? [payload as AdjustmentInput]
63
- : []
84
+ if (!payload) throw new ApiError('E01', 'Request body must contain data')
64
85
 
65
- if (!adjustments.length) {
66
- throw new ApiError('E01', 'adjustments')
67
- }
86
+ const entries: NormalizedEntry[] = normalizeAdjustmentEntries(payload)
87
+ if (!entries.length)
88
+ throw new ApiError(
89
+ 'E01',
90
+ 'No valid adjustment entries found. Provide create or adjustments array with valid items'
91
+ )
68
92
 
69
93
  // Build patches with deterministic targeting (resolve inventory by pallet/carton/batch/location)
70
94
  await getConnection().transaction(async (tx: EntityManager) => {
71
95
  const patches: InventoryPatch[] = []
72
96
  const correlationIds: string[] = []
73
97
 
74
- for (const [index, adj] of adjustments.entries()) {
75
- const target: TargetSelector = adj?.target || {}
76
- const rawChanges: any = adj?.changes || {}
98
+ let strictProduct: boolean = false
99
+
100
+ const strictProductSelectionSetting: Setting = await tx.getRepository(Setting).findOne({
101
+ where: { domain, category: 'id-rule', name: 'strict-product-selection' }
102
+ })
103
+
104
+ if (strictProductSelectionSetting) strictProduct = strictProductSelectionSetting.value
105
+
106
+ for (const entry of entries) {
107
+ const indexLabel = `${entry.source}[${entry.index}]`
108
+ const correlationId = `rest-adjustment:${uuidv4()}`
109
+
110
+ let rawPatch: any
111
+ let patch: InventoryPatch
112
+ let inventory: Inventory | null = null
113
+ let target: TargetSelector | null = null
114
+
115
+ let rawSerialNumbers: string | string[] | undefined
116
+ let resolvedProductDetail: ProductDetail | null = null
117
+
118
+ if (entry.kind === 'create') {
119
+ rawPatch = entry.payload || {}
120
+ rawSerialNumbers = rawPatch?.serialNumbers
121
+ patch = massageChanges({ ...rawPatch })
122
+
123
+ const sku = rawPatch?.sku
124
+ const refCode: string | undefined = rawPatch?.refCode
125
+ const packingType: string | undefined = rawPatch?.packingType
126
+ const packingSize: number | undefined = rawPatch?.packingSize
127
+
128
+ if (!refCode) {
129
+ throw new ApiError('E01', `refCode is required at ${indexLabel}`)
130
+ }
131
+
132
+ const bizplaceId =
133
+ rawPatch?.bizplaceId ||
134
+ rawPatch?.bizplace?.id ||
135
+ (patch?.bizplace as any)?.id ||
136
+ entry.payload?.bizplaceId
137
+
138
+ if (!bizplaceId) {
139
+ throw new ApiError('E01', `bizplaceId is required at ${indexLabel}`)
140
+ }
141
+
142
+ const companyBizplace: Bizplace = await getCompanyBizplace(domain, null, bizplaceId, tx)
143
+ const companyDomainId = (companyBizplace?.domain as any)?.id
144
+
145
+ const productDetail: ProductDetail | undefined = await tx
146
+ .getRepository(ProductDetail)
147
+ .createQueryBuilder('pd')
148
+ .innerJoinAndSelect('pd.product', 'product')
149
+ .where('pd.domain = :domain', { domain: companyDomainId })
150
+ .andWhere('pd.deleted_at ISNULL')
151
+ .andWhere('pd.ref_code = :refCode', { refCode })
152
+ .getOne()
153
+
154
+ if (!productDetail) {
155
+ throw new ApiError('E04', 'productDetail not found (invalid sku, refCode, packingType, or packingSize)')
156
+ }
157
+
158
+ // Strict product validation: ensure provided packingType and packingSize match productDetail
159
+ if (strictProduct) {
160
+ if (packingType && packingType !== productDetail.packingType) {
161
+ throw new ApiError(
162
+ 'E04',
163
+ `packingType mismatch: provided '${packingType}' but productDetail has '${productDetail.packingType}' at ${indexLabel}`
164
+ )
165
+ }
166
+ if (packingSize && packingSize !== productDetail.packingSize) {
167
+ throw new ApiError(
168
+ 'E04',
169
+ `packingSize mismatch: provided '${packingSize}' but productDetail has '${productDetail.packingSize}' at ${indexLabel}`
170
+ )
171
+ }
172
+ }
173
+
174
+ patch.productDetailId = productDetail.id
175
+ resolvedProductDetail = productDetail
176
+ if (!patch.sku && productDetail?.product?.sku) {
177
+ patch.sku = productDetail.product.sku
178
+ }
179
+
180
+ // Assign packingType and packingSize from productDetail if not provided
181
+ if (!packingType && productDetail.packingType) {
182
+ patch.packingType = productDetail.packingType
183
+ }
184
+ if (!packingSize && productDetail.packingSize) {
185
+ patch.packingSize = productDetail.packingSize
186
+ }
187
+ } else if (entry.kind === 'adjust') {
188
+ target = entry.payload?.target || {}
189
+ rawPatch = entry?.payload.changes && typeof entry.payload.changes === 'object' ? entry.payload.changes : {}
190
+ rawSerialNumbers = rawPatch?.serialNumbers
191
+
192
+ // Resolve inventory by target selector. Require at least one selector.
193
+ if (!target?.palletId && !target?.cartonId && !target?.batchId && !target?.locationName) {
194
+ throw new ApiError('E01', `target (need palletId/cartonId/batchId/locationName) at ${indexLabel}`)
195
+ }
196
+
197
+ inventory = await findSingleInventoryByTarget(tx, domain.id, target)
198
+
199
+ // Map human-friendly keys to InventoryPatch structure
200
+ patch = massageChanges(rawPatch)
201
+
202
+ // Attach the resolved inventory identifiers so the mutation updates existing record
203
+ patch.id = inventory.id
204
+ patch.palletId = inventory.palletId
205
+ } else {
206
+ continue
207
+ }
77
208
 
78
209
  // Validate required fields
79
- const adjustmentCode: string | undefined = rawChanges?.adjustmentCode || rawChanges?.adjustment_code
80
- const adjustmentNote: string | undefined = rawChanges?.adjustmentNote || rawChanges?.adjustment_note
210
+ const adjustmentCode: string | undefined = rawPatch?.adjustmentCode || rawPatch?.adjustment_code
211
+ const adjustmentNote: string | undefined = rawPatch?.adjustmentNote || rawPatch?.adjustment_note
81
212
 
82
213
  if (!adjustmentCode) {
83
- throw new ApiError('E01', `adjustmentCode (adjustmentCode is required) at index ${index}`)
214
+ throw new ApiError('E01', `adjustmentCode (required) at ${indexLabel}`)
84
215
  }
85
216
  if (!adjustmentNote) {
86
- throw new ApiError('E01', `adjustmentNote (adjustmentNote is required) at index ${index}`)
217
+ throw new ApiError('E01', `adjustmentNote (required) at ${indexLabel}`)
87
218
  }
88
219
  if (!ALLOWED_ADJUSTMENT_CODES.includes(adjustmentCode)) {
89
- throw new ApiError('E01', `Invalid adjustmentCode '${adjustmentCode}' at index ${index}`)
220
+ throw new ApiError('E01', `Invalid adjustmentCode '${adjustmentCode}' at ${indexLabel}`)
90
221
  }
91
222
 
92
- // Resolve inventory by target selector. Require at least one selector.
93
- if (!target?.palletId && !target?.cartonId && !target?.batchId && !target?.locationName) {
94
- throw new ApiError('E01', `target (need palletId/cartonId/batchId/locationName) at index ${index}`)
95
- }
96
-
97
- const inventory: Inventory = await findSingleInventoryByTarget(tx, domain.id, target)
98
-
99
- // Map human-friendly keys to InventoryPatch structure
100
- const changes: InventoryPatch = massageChanges(rawChanges)
101
-
102
- // Attach the resolved inventory identifiers so the mutation updates existing record
103
- changes.id = inventory.id
104
- changes.palletId = inventory.palletId
105
-
106
223
  // Enforce required note/code
107
- changes.adjustmentCode = adjustmentCode
108
- changes.adjustmentNote = adjustmentNote
224
+ patch.adjustmentCode = adjustmentCode
225
+ patch.adjustmentNote = adjustmentNote
109
226
 
110
227
  // Correlate each generated InventoryChange using details field
111
- const correlationId = `rest-adjustment:${uuidv4()}`
112
- ;(changes as any).details = correlationId
228
+ ;(patch as any).details = correlationId
113
229
  correlationIds.push(correlationId)
114
230
 
115
- // If sku is changed, require refCode and resolve productDetailId to ensure exact match
116
- if (changes?.sku && changes.sku !== inventory?.product?.sku) {
117
- const refCode: string | undefined = rawChanges?.refCode
118
- if (!refCode) {
119
- throw new ApiError('E01', 'changes.refCode (required when changing sku)')
231
+ let serialProduct: Product | null = null
232
+
233
+ if (inventory && target) {
234
+ // If sku is changed, resolve productDetailId using sku, refCode, packingType, and packingSize
235
+ if (patch?.sku && patch.sku !== inventory?.product?.sku) {
236
+ const refCode: string | undefined = rawPatch?.refCode
237
+ const packingType: string | undefined = rawPatch?.packingType
238
+ const packingSize: number | undefined = rawPatch?.packingSize
239
+
240
+ if (!refCode) {
241
+ throw new ApiError('E01', 'adjustment.changes.refCode (required when changing sku)')
242
+ }
243
+
244
+ const companyBizplace: any = await getCompanyBizplace(
245
+ domain,
246
+ null,
247
+ target.bizplaceId || inventory?.bizplace?.id,
248
+ tx
249
+ )
250
+
251
+ const companyDomainId = (companyBizplace?.domain as any)?.id
252
+
253
+ const pd: ProductDetail | undefined = await tx
254
+ .getRepository(ProductDetail)
255
+ .createQueryBuilder('pd')
256
+ .innerJoinAndSelect('pd.product', 'prod')
257
+ .where('pd.domain = :domain', { domain: companyDomainId })
258
+ .andWhere('prod.sku = :sku', { sku: patch.sku })
259
+ .andWhere('pd.deleted_at ISNULL')
260
+ .andWhere('pd.ref_code = :refCode', { refCode })
261
+ .getOne()
262
+
263
+ if (!pd) {
264
+ throw new ApiError('E04', 'productDetail not found (by sku, refCode, packingType, and packingSize)')
265
+ }
266
+
267
+ // Strict product validation for adjustments: ensure provided packingType and packingSize match productDetail
268
+ if (strictProduct) {
269
+ if (packingType && packingType !== pd.packingType) {
270
+ throw new ApiError(
271
+ 'E04',
272
+ `packingType mismatch in adjustment: provided '${packingType}' but productDetail has '${pd.packingType}' at ${indexLabel}`
273
+ )
274
+ }
275
+ if (packingSize && packingSize !== pd.packingSize) {
276
+ throw new ApiError(
277
+ 'E04',
278
+ `packingSize mismatch in adjustment: provided '${packingSize}' but productDetail has '${pd.packingSize}' at ${indexLabel}`
279
+ )
280
+ }
281
+ }
282
+
283
+ if (strictProduct) {
284
+ patch.packingType = pd.packingType
285
+ patch.packingSize = pd.packingSize
286
+ }
287
+
288
+ ;(patch as any).productDetailId = (pd as any).id
289
+ resolvedProductDetail = pd as ProductDetail
120
290
  }
121
- const companyBizplace: any = await getCompanyBizplace(
122
- domain,
123
- null,
124
- target.bizplaceId || inventory?.bizplace?.id,
125
- tx
126
- )
127
- const domainId = (companyBizplace?.domain as any)?.id
128
- const pd = await tx
129
- .getRepository(ProductDetail)
130
- .createQueryBuilder('pd')
131
- .innerJoinAndSelect('pd.product', 'prod')
132
- .where('pd.domain = :domain', { domain: domainId })
133
- .andWhere('prod.sku = :sku', { sku: changes.sku })
134
- .andWhere('pd.refCode = :refCode', { refCode })
135
- .getOne()
136
- if (!pd) {
137
- throw new ApiError('E04', 'productDetail (by sku and refCode)')
138
- }
139
- ;(changes as any).productDetailId = (pd as any).id
291
+
292
+ serialProduct = (resolvedProductDetail || inventory.product) as Product
293
+ }
294
+
295
+ if (!serialProduct && resolvedProductDetail) {
296
+ serialProduct = resolvedProductDetail?.product || serialProduct
140
297
  }
141
298
 
142
- patches.push(changes)
299
+ const needsSerialNumbers = productRequiresSerialTracking(serialProduct)
300
+ if (needsSerialNumbers && !defined(rawSerialNumbers)) {
301
+ throw new ApiError('E01', `serialNumbers (required for serial-tracked product) at ${indexLabel}`)
302
+ }
303
+ if (defined(rawSerialNumbers)) {
304
+ patch.serialNumbers = normalizeSerialNumbersValue(rawSerialNumbers)
305
+ }
306
+
307
+ patches.push(patch)
143
308
  }
144
309
 
145
310
  // Submit via shared service to preserve core business logic
@@ -185,6 +350,30 @@ router.post(
185
350
  }
186
351
  )
187
352
 
353
+ function normalizeAdjustmentEntries(payload: any): NormalizedEntry[] {
354
+ const entries: NormalizedEntry[] = []
355
+
356
+ if (payload?.create && !Array.isArray(payload.create)) {
357
+ throw new ApiError('E01', 'create must be an array')
358
+ }
359
+ if (payload?.adjustments && !Array.isArray(payload.adjustments)) {
360
+ throw new ApiError('E01', 'adjustments must be an array')
361
+ }
362
+
363
+ const sources = [
364
+ { data: payload?.create || [], kind: 'create', source: 'create' },
365
+ { data: payload?.adjustments || [], kind: 'adjust', source: 'adjustments' }
366
+ ]
367
+
368
+ entries.push(
369
+ ...sources.flatMap(({ data, kind, source }) =>
370
+ data.map((item, idx) => item && { kind, payload: item, source, index: idx }).filter(Boolean)
371
+ )
372
+ )
373
+
374
+ return entries
375
+ }
376
+
188
377
  async function findSingleInventoryByTarget(
189
378
  tx: EntityManager,
190
379
  domainId: string,
@@ -227,33 +416,19 @@ async function findSingleInventoryByTarget(
227
416
  }
228
417
 
229
418
  function massageChanges(input: any): InventoryPatch {
230
- // Normalize incoming keys (support snake_case & alternate names)
231
419
  const changes = { ...input }
232
420
 
233
- // Field aliases from requirement list
234
- if (changes.batchNo && !changes.batchId) changes.batchId = changes.batchNo
235
- if (changes.batchNoRef && !changes.batchIdRef) changes.batchIdRef = changes.batchNoRef
236
- if (changes.company && !changes.bizplaceId) changes.bizplaceId = changes.company
237
- if (changes.totalUomValue && !changes.uomValue) changes.uomValue = Number(changes.totalUomValue)
238
- if (changes.expDate && !changes.expirationDate) changes.expirationDate = changes.expDate
239
- if (changes.location && typeof changes.location === 'string') {
240
- changes.locationName = changes.location
241
- delete changes.location
242
- }
243
-
244
421
  // Build InventoryPatch with supported fields only
245
422
  const patch: InventoryPatch = {}
246
423
 
247
424
  assignIfDefined(patch, 'bizplace', changes.bizplace)
248
425
  if (changes.bizplaceId) patch.bizplace = { id: String(changes.bizplaceId) } as any
249
426
 
250
- // Identify product by productDetailId or sku
251
- if (changes.productDetailId) assignIfDefined(patch, 'productDetailId', String(changes.productDetailId))
427
+ // Identify product by sku
252
428
  if (changes.sku) assignIfDefined(patch, 'sku', String(changes.sku))
253
429
  if (changes.productName) assignIfDefined(patch, 'productName', String(changes.productName))
254
430
 
255
431
  // Location can be id or name
256
- if (changes.locationId) patch.location = { id: String(changes.locationId) } as any
257
432
  if (changes.locationName) patch.location = { name: String(changes.locationName) } as any
258
433
 
259
434
  assignIfDefined(patch, 'batchId', changes.batchId)
@@ -264,7 +439,6 @@ function massageChanges(input: any): InventoryPatch {
264
439
  if (defined(changes.qty)) patch.qty = Number(changes.qty)
265
440
  if (defined(changes.uomValue)) patch.uomValue = Number(changes.uomValue)
266
441
  if (defined(changes.uom)) patch.uom = String(changes.uom).trim()
267
- assignIfDefined(patch, 'serialNumbers', changes.serialNumbers)
268
442
  assignIfDefined(patch, 'remark', changes.remark)
269
443
  assignIfDefined(patch, 'expirationDate', changes.expirationDate)
270
444
  assignIfDefined(patch, 'manufactureDate', changes.manufactureDate)
@@ -275,6 +449,25 @@ function massageChanges(input: any): InventoryPatch {
275
449
  return patch
276
450
  }
277
451
 
452
+ function productRequiresSerialTracking(product?: Product | null): boolean {
453
+ return !!(product?.isRequireSerialNumberScanning || product?.isRequireSerialNumberScanningInbound)
454
+ }
455
+
456
+ function normalizeSerialNumbersValue(serialNumbers: string | string[]): string {
457
+ if (Array.isArray(serialNumbers)) {
458
+ return serialNumbers
459
+ .map(sn => String(sn || '').trim())
460
+ .filter(Boolean)
461
+ .join(',')
462
+ }
463
+
464
+ return String(serialNumbers || '')
465
+ .split(',')
466
+ .map(sn => sn.trim())
467
+ .filter(Boolean)
468
+ .join(',')
469
+ }
470
+
278
471
  function assignIfDefined<T extends object, K extends keyof any>(obj: T, key: K, value: any) {
279
472
  if (defined(value)) (obj as any)[key] = value
280
473
  }