@things-factory/operato-hub 4.3.744 → 4.3.745

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 (25) hide show
  1. package/dist-server/routers/api/restful-apis/v1/company/add-contact-points.js +71 -2
  2. package/dist-server/routers/api/restful-apis/v1/company/add-contact-points.js.map +1 -1
  3. package/dist-server/routers/api/restful-apis/v1/company/index.js +1 -0
  4. package/dist-server/routers/api/restful-apis/v1/company/index.js.map +1 -1
  5. package/dist-server/routers/api/restful-apis/v1/company/update-contact-points.js +243 -0
  6. package/dist-server/routers/api/restful-apis/v1/company/update-contact-points.js.map +1 -0
  7. package/dist-server/routers/api/restful-apis/v1/utils/params.js +109 -28
  8. package/dist-server/routers/api/restful-apis/v1/utils/params.js.map +1 -1
  9. package/dist-server/routers/api/restful-apis/v1/warehouse/index.js +1 -0
  10. package/dist-server/routers/api/restful-apis/v1/warehouse/index.js.map +1 -1
  11. package/dist-server/routers/api/restful-apis/v1/warehouse/update-arrival-notice.js +365 -0
  12. package/dist-server/routers/api/restful-apis/v1/warehouse/update-arrival-notice.js.map +1 -0
  13. package/dist-server/routers/api/restful-apis/v1/warehouse/update-release-order-details.js +946 -19
  14. package/dist-server/routers/api/restful-apis/v1/warehouse/update-release-order-details.js.map +1 -1
  15. package/openapi/v1/contact-point.yaml +266 -0
  16. package/openapi/v1/inbound.yaml +349 -0
  17. package/openapi/v1/outbound.yaml +256 -150
  18. package/package.json +18 -18
  19. package/server/routers/api/restful-apis/v1/company/add-contact-points.ts +91 -2
  20. package/server/routers/api/restful-apis/v1/company/index.ts +1 -0
  21. package/server/routers/api/restful-apis/v1/company/update-contact-points.ts +267 -0
  22. package/server/routers/api/restful-apis/v1/utils/params.ts +109 -28
  23. package/server/routers/api/restful-apis/v1/warehouse/index.ts +1 -0
  24. package/server/routers/api/restful-apis/v1/warehouse/update-arrival-notice.ts +429 -0
  25. package/server/routers/api/restful-apis/v1/warehouse/update-release-order-details.ts +1122 -29
@@ -1,15 +1,58 @@
1
- import { EntityManager, getConnection } from 'typeorm'
1
+ import { EntityManager, getConnection, getRepository, In, SelectQueryBuilder } from 'typeorm'
2
+ import { v4 as uuidv4 } from 'uuid'
2
3
 
3
4
  import { restfulApiRouter as router } from '@things-factory/api'
4
5
  import { User } from '@things-factory/auth-base'
5
- import { getMyBizplace } from '@things-factory/biz-base'
6
- import { ORDER_STATUS, OrderNoGenerator, ReleaseGood, ShippingOrder } from '@things-factory/sales-base'
6
+ import { Bizplace, ContactPoint, getCompanyBizplace, getMyBizplace } from '@things-factory/biz-base'
7
+ import { Product, ProductDetail } from '@things-factory/product-base'
8
+ import {
9
+ ORDER_INVENTORY_STATUS,
10
+ ORDER_PRODUCT_STATUS,
11
+ ORDER_STATUS,
12
+ ORDER_TYPES,
13
+ OrderNoGenerator,
14
+ OrderInventory,
15
+ OrderProduct,
16
+ ReleaseGood,
17
+ ShippingOrder
18
+ } from '@things-factory/sales-base'
19
+ import { Setting } from '@things-factory/setting-base'
20
+ import { Domain } from '@things-factory/shell'
21
+ import { Inventory, INVENTORY_STATUS, LOCATION_TYPE, ProductDetailStock } from '@things-factory/warehouse-base'
22
+ import {
23
+ Worksheet,
24
+ WorksheetDetail,
25
+ WORKSHEET_STATUS,
26
+ WORKSHEET_TYPE,
27
+ WorksheetNoGenerator
28
+ } from '@things-factory/worksheet-base'
7
29
 
8
30
  import { businessMiddleware, loggingMiddleware, validationMiddleware } from '../middlewares'
9
31
  import { ApiError, ApiErrorHandler, throwInternalServerError } from '../utils/error-util'
10
32
 
11
33
  const debug = require('debug')('things-factory:operato-hub:restful-api:v1:update-release-order-details')
12
34
 
35
+ // Allowed statuses for updates (order information only)
36
+ const ALLOWED_STATUSES_FOR_INFO_UPDATE = [
37
+ ORDER_STATUS.PENDING,
38
+ ORDER_STATUS.PENDING_RECEIVE,
39
+ ORDER_STATUS.PENDING_WORKSHEET,
40
+ ORDER_STATUS.READY_TO_PICK,
41
+ ORDER_STATUS.PICKING,
42
+ ORDER_STATUS.READY_TO_LOAD,
43
+ ORDER_STATUS.LOADING,
44
+ ORDER_STATUS.READY_TO_PACK,
45
+ ORDER_STATUS.PACKING
46
+ ]
47
+
48
+ // Allowed statuses for order product updates
49
+ const ALLOWED_STATUSES_FOR_PRODUCT_UPDATE = [
50
+ ORDER_STATUS.PENDING,
51
+ ORDER_STATUS.PENDING_RECEIVE,
52
+ ORDER_STATUS.PENDING_WORKSHEET,
53
+ ORDER_STATUS.READY_TO_PICK
54
+ ]
55
+
13
56
  router.post(
14
57
  '/v1/warehouse/update-release-order-details',
15
58
  businessMiddleware,
@@ -27,11 +70,12 @@ router.post(
27
70
 
28
71
  let patch: any = {}
29
72
  patch.releaseGood = bodyReq.releaseGood
73
+ patch.orderProducts = bodyReq.orderProducts || []
30
74
 
31
75
  if (bodyReq?.shippingOrder) patch.shippingOrder = bodyReq.shippingOrder
32
76
  else patch.shippingOrder = null
33
77
 
34
- context.body = await updateReleaseGoodDetails(tx, domain, patch, user)
78
+ context.body = await updateReleaseGoodDetails(tx, domain, patch, user, context)
35
79
  })
36
80
  } catch (e) {
37
81
  if (e instanceof ApiError) ApiErrorHandler(context, e)
@@ -40,40 +84,57 @@ router.post(
40
84
  }
41
85
  )
42
86
 
43
- async function updateReleaseGoodDetails(tx, domain, patch, user) {
44
- let { releaseGood, shippingOrder } = patch
45
- let status = [ORDER_STATUS.DONE, ORDER_STATUS.CANCELLED, ORDER_STATUS.REJECTED]
46
-
87
+ async function updateReleaseGoodDetails(tx: EntityManager, domain: Domain, patch: any, user: User, context: any) {
88
+ let { releaseGood, shippingOrder, orderProducts: inputOrderProducts } = patch
89
+
47
90
  // Build where clause: prioritize refOrderId over roId
48
91
  let whereClause: any = { domain }
49
92
  const refOrderId = releaseGood?.refOrderId
50
93
  const hasRefOrderId = refOrderId != null && refOrderId !== ''
51
-
94
+ const roId = releaseGood?.roId
95
+
52
96
  if (hasRefOrderId) {
53
97
  whereClause.refOrderId = refOrderId
54
- } else if (releaseGood?.roId) {
55
- whereClause.id = releaseGood.roId
98
+ } else if (roId) {
99
+ whereClause.id = roId
56
100
  } else {
57
101
  throw new ApiError('E04', 'release order: roId or refOrderId is required')
58
102
  }
59
-
103
+
60
104
  let foundReleaseGood = await tx.getRepository(ReleaseGood).findOne({
61
105
  where: whereClause,
62
- relations: ['shippingOrder']
106
+ relations: ['bizplace', 'shippingOrder', 'orderProducts', 'orderProducts.product', 'orderProducts.productDetail']
63
107
  })
64
108
 
65
109
  if (!foundReleaseGood) {
66
- const searchId = hasRefOrderId
67
- ? `refOrderId: ${refOrderId}`
68
- : `roId: ${releaseGood?.roId}`
110
+ const searchId = hasRefOrderId ? `refOrderId: ${refOrderId}` : `roId: ${roId}`
69
111
  throw new ApiError('E04', `release order: ${searchId}`)
70
112
  }
71
113
 
72
- if (status.indexOf(foundReleaseGood.status) !== -1) {
73
- throw new ApiError('E04', `release order status: ${foundReleaseGood.status}`)
114
+ // Validate status for order information updates
115
+ const releaseGoodStatus = (foundReleaseGood as ReleaseGood).status
116
+ if (!ALLOWED_STATUSES_FOR_INFO_UPDATE.includes(releaseGoodStatus)) {
117
+ throw new ApiError(
118
+ 'E04',
119
+ `Release order status "${releaseGoodStatus}" does not allow updates. Allowed statuses: ${ALLOWED_STATUSES_FOR_INFO_UPDATE.join(
120
+ ', '
121
+ )}`
122
+ )
123
+ }
124
+
125
+ // Validate status for order product updates
126
+ if (inputOrderProducts && inputOrderProducts.length > 0) {
127
+ if (!ALLOWED_STATUSES_FOR_PRODUCT_UPDATE.includes(releaseGoodStatus)) {
128
+ throw new ApiError(
129
+ 'E04',
130
+ `Order product updates are not allowed when release order status is "${releaseGoodStatus}". Order products can only be updated when status is: ${ALLOWED_STATUSES_FOR_PRODUCT_UPDATE.join(
131
+ ', '
132
+ )}`
133
+ )
134
+ }
74
135
  }
75
136
 
76
- // case to update existing shippingOrder
137
+ // Update shipping order if provided
77
138
  if (shippingOrder !== null) {
78
139
  shippingOrder.remark = shippingOrder.exportRemark
79
140
  shippingOrder.containerClosureDateTime = shippingOrder.containerClosureDate
@@ -82,11 +143,11 @@ async function updateReleaseGoodDetails(tx, domain, patch, user) {
82
143
  delete shippingOrder.exportRemark
83
144
  }
84
145
 
85
- if (foundReleaseGood.shippingOrder && shippingOrder) {
86
- await tx.getRepository(ShippingOrder).update(foundReleaseGood.shippingOrder.id, shippingOrder)
87
- }
88
- // case for new shippingOrder
89
- else if (!foundReleaseGood.shippingOrder && shippingOrder) {
146
+ const releaseGoodEntityForShipping = foundReleaseGood as ReleaseGood
147
+ const foundShippingOrder = releaseGoodEntityForShipping.shippingOrder as ShippingOrder | null
148
+ if (foundShippingOrder && shippingOrder) {
149
+ await tx.getRepository(ShippingOrder).update(foundShippingOrder.id, shippingOrder)
150
+ } else if (!foundShippingOrder && shippingOrder) {
90
151
  let newShippingOrder: ShippingOrder = await tx.getRepository(ShippingOrder).save({
91
152
  ...shippingOrder,
92
153
  name: OrderNoGenerator.shippingOrder(),
@@ -100,11 +161,1043 @@ async function updateReleaseGoodDetails(tx, domain, patch, user) {
100
161
  releaseGood.shippingOrder = newShippingOrder
101
162
  }
102
163
 
103
- foundReleaseGood = await tx.getRepository(ReleaseGood).save({
104
- ...foundReleaseGood,
105
- ...releaseGood,
106
- remark: releaseGood?.remark ? releaseGood.remark : null
164
+ // Update order products if provided
165
+ if (inputOrderProducts && inputOrderProducts.length > 0) {
166
+ await updateOrderProducts(tx, foundReleaseGood, inputOrderProducts, domain, user, context)
167
+ }
168
+
169
+ // Update release good fields
170
+ const updateData: any = {
171
+ remark: releaseGood?.remark ? releaseGood.remark : null,
172
+ updater: user
173
+ }
174
+
175
+ const releaseGoodPatch = releaseGood as any
176
+ if (releaseGoodPatch?.refNo !== undefined) updateData.refNo = releaseGoodPatch.refNo
177
+ if (releaseGoodPatch?.refNo2 !== undefined) updateData.refNo2 = releaseGoodPatch.refNo2
178
+ if (releaseGoodPatch?.refNo3 !== undefined) updateData.refNo3 = releaseGoodPatch.refNo3
179
+ if (releaseGoodPatch?.description !== undefined) updateData.description = releaseGoodPatch.description
180
+ if (releaseGoodPatch?.truckNo !== undefined) updateData.truckNo = releaseGoodPatch.truckNo
181
+ if (releaseGoodPatch?.collectionOrderNo !== undefined)
182
+ updateData.collectionOrderNo = releaseGoodPatch.collectionOrderNo
183
+ if (releaseGoodPatch?.releaseDate !== undefined) updateData.releaseDate = releaseGoodPatch.releaseDate
184
+ if (releaseGoodPatch?.ownTransport !== undefined) updateData.ownTransport = releaseGoodPatch.ownTransport
185
+ if (releaseGoodPatch?.exportOption !== undefined) updateData.exportOption = releaseGoodPatch.exportOption
186
+ if (releaseGoodPatch?.packingOption !== undefined) updateData.packingOption = releaseGoodPatch.packingOption
187
+ if (releaseGoodPatch?.courierOption !== undefined) updateData.courierOption = releaseGoodPatch.courierOption
188
+ if (releaseGoodPatch?.shippingOrder !== undefined) updateData.shippingOrder = releaseGoodPatch.shippingOrder
189
+
190
+ const releaseGoodEntityForUpdate = foundReleaseGood as ReleaseGood
191
+ // Exclude orderProducts relation to prevent TypeORM from syncing and clearing foreign keys
192
+ const { orderProducts, orderInventories, deliveryOrders, ...releaseGoodWithoutRelations } = releaseGoodEntityForUpdate
193
+ await tx.getRepository(ReleaseGood).save({
194
+ ...releaseGoodWithoutRelations,
195
+ ...updateData
107
196
  })
108
197
 
109
- return foundReleaseGood
198
+ // Fetch updated release good with relations for response
199
+ // Query order products separately to ensure we get all of them including newly added ones
200
+ const releaseGoodId = (foundReleaseGood as ReleaseGood).id
201
+
202
+ // Fetch release good basic info
203
+ const updatedReleaseGood: ReleaseGood | undefined = await tx.getRepository(ReleaseGood).findOne({
204
+ where: { id: releaseGoodId },
205
+ relations: ['shippingOrder']
206
+ })
207
+
208
+ if (!updatedReleaseGood) {
209
+ throw new ApiError('E04', 'Failed to retrieve updated release order')
210
+ }
211
+
212
+ // Fetch all order products separately to ensure we get newly added ones
213
+ const allOrderProducts = await tx.getRepository(OrderProduct).find({
214
+ where: { releaseGood: { id: releaseGoodId } },
215
+ relations: ['product', 'productDetail']
216
+ })
217
+
218
+ // Attach order products to release good
219
+ updatedReleaseGood.orderProducts = allOrderProducts
220
+
221
+ const releaseGoodForResponse = updatedReleaseGood as ReleaseGood
222
+
223
+ // Format response - only include necessary fields
224
+ const resultOrderProducts = (releaseGoodForResponse.orderProducts || []).map((op: OrderProduct) => ({
225
+ id: op.id,
226
+ product: {
227
+ sku: op.product?.sku || null,
228
+ name: op.product?.name || null
229
+ },
230
+ productDetail: {
231
+ refCode: (op.productDetail as any)?.refCode || null
232
+ },
233
+ batchId: op.batchId,
234
+ packingType: op.packingType,
235
+ packingSize: op.packingSize,
236
+ releaseQty: op.releaseQty,
237
+ releaseUomValue: op.releaseUomValue,
238
+ uom: op.uom,
239
+ refItemId: op.refItemId || null,
240
+ unitPrice: op.unitPrice || null,
241
+ remark: op.remark || null,
242
+ status: op.status
243
+ }))
244
+
245
+ const shippingOrderData = releaseGoodForResponse.shippingOrder
246
+ ? {
247
+ id: (releaseGoodForResponse.shippingOrder as ShippingOrder).id,
248
+ name: (releaseGoodForResponse.shippingOrder as ShippingOrder).name,
249
+ status: (releaseGoodForResponse.shippingOrder as ShippingOrder).status
250
+ }
251
+ : null
252
+
253
+ const data = {
254
+ id: releaseGoodForResponse.id,
255
+ roNo: releaseGoodForResponse.name,
256
+ refOrderId: releaseGoodForResponse.refOrderId,
257
+ refNo: releaseGoodForResponse.refNo,
258
+ refNo2: releaseGoodForResponse.refNo2,
259
+ refNo3: releaseGoodForResponse.refNo3,
260
+ description: releaseGoodForResponse.description,
261
+ truckNo: releaseGoodForResponse.truckNo,
262
+ collectionOrderNo: releaseGoodForResponse.collectionOrderNo,
263
+ releaseDate: releaseGoodForResponse.releaseDate,
264
+ ownTransport: releaseGoodForResponse.ownTransport,
265
+ exportOption: releaseGoodForResponse.exportOption,
266
+ packingOption: releaseGoodForResponse.packingOption,
267
+ courierOption: releaseGoodForResponse.courierOption,
268
+ status: releaseGoodForResponse.status,
269
+ remark: releaseGoodForResponse.remark,
270
+ orderProducts: resultOrderProducts,
271
+ shippingOrder: shippingOrderData
272
+ }
273
+
274
+ return {
275
+ responseCode: '200',
276
+ message: 'success',
277
+ data
278
+ }
279
+ }
280
+
281
+ async function updateOrderProducts(
282
+ tx: EntityManager,
283
+ releaseGood: ReleaseGood,
284
+ inputOrderProducts: any[],
285
+ domain: Domain,
286
+ user: User,
287
+ context: any
288
+ ) {
289
+ // Get existing order products with their order inventories
290
+ const existingOrderProducts = await tx.getRepository(OrderProduct).find({
291
+ where: { releaseGood },
292
+ relations: ['product', 'productDetail', 'orderInventories', 'orderInventories.inventory']
293
+ })
294
+
295
+ // Determine hasOrderInventories by checking if releaseGood has any orderInventories
296
+ // Check directly on releaseGood by counting orderInventories
297
+ const orderInventoriesCount = await tx.getRepository(OrderInventory).count({
298
+ where: { releaseGood }
299
+ })
300
+ const hasOrderInventories = orderInventoriesCount > 0
301
+
302
+ // Process removals first
303
+ const orderProductIdsToRemove: string[] = []
304
+ const orderProductsToUpdate: any[] = []
305
+ const orderProductsToAdd: any[] = []
306
+
307
+ for (const orderProductReq of inputOrderProducts) {
308
+ if (orderProductReq._action === 'remove') {
309
+ const matchingOP = findMatchingOrderProduct(existingOrderProducts, orderProductReq)
310
+ if (!matchingOP) {
311
+ throw new ApiError(
312
+ 'E04',
313
+ `Order product not found for removal. Provide id, sku, refCode, batchId, packingType, or refItemId to identify the item.`
314
+ )
315
+ }
316
+ orderProductIdsToRemove.push(matchingOP.id)
317
+ continue
318
+ }
319
+
320
+ // Try to find existing order product for update
321
+ const matchingOP = findMatchingOrderProduct(existingOrderProducts, orderProductReq)
322
+
323
+ if (matchingOP) {
324
+ orderProductsToUpdate.push({ existing: matchingOP, request: orderProductReq })
325
+ } else {
326
+ orderProductsToAdd.push(orderProductReq)
327
+ }
328
+ }
329
+
330
+ // Validate: Prevent removing all products from the order
331
+ // If removing products would leave the order with zero products, throw an error
332
+ const existingProductCount = existingOrderProducts.length
333
+ const productsToRemoveCount = orderProductIdsToRemove.length
334
+ const productsToAddCount = orderProductsToAdd.length
335
+ const remainingProductCount = existingProductCount - productsToRemoveCount + productsToAddCount
336
+
337
+ if (remainingProductCount <= 0) {
338
+ throw new ApiError(
339
+ 'E04',
340
+ 'Cannot remove all products from the release order. Please use the cancellation API to cancel the entire order instead of removing the final item(s).'
341
+ )
342
+ }
343
+
344
+ // Remove order products
345
+ if (orderProductIdsToRemove.length > 0) {
346
+ await removeOrderProducts(tx, orderProductIdsToRemove, hasOrderInventories)
347
+ }
348
+
349
+ // Update existing order products
350
+ for (const { existing, request } of orderProductsToUpdate) {
351
+ await updateExistingOrderProduct(tx, existing, request, releaseGood, domain, user, context, hasOrderInventories)
352
+ }
353
+
354
+ // Add new order products
355
+ for (const orderProductReq of orderProductsToAdd) {
356
+ await addNewOrderProduct(tx, orderProductReq, releaseGood, domain, user, context, hasOrderInventories)
357
+ }
358
+ }
359
+
360
+ function findMatchingOrderProduct(existingOrderProducts: OrderProduct[], orderProductReq: any): OrderProduct | null {
361
+ // First try by ID if provided
362
+ if (orderProductReq.id) {
363
+ const found = existingOrderProducts.find(op => op.id === orderProductReq.id)
364
+ if (found) return found
365
+ }
366
+
367
+ // Extract matching fields
368
+ const sku = orderProductReq.product?.sku || orderProductReq.sku
369
+ const refCode = orderProductReq.product?.refCode || orderProductReq.productDetail?.refCode || orderProductReq.refCode
370
+ const batchId = orderProductReq.batchId
371
+ const packingType = orderProductReq.packingType
372
+ const refItemId = orderProductReq.refItemId
373
+
374
+ // Need at least one matching field (besides id)
375
+ if (!sku && !refCode && !batchId && !packingType && !refItemId) {
376
+ return null
377
+ }
378
+
379
+ // Match by provided fields (all provided fields must match)
380
+ return (
381
+ existingOrderProducts.find(op => {
382
+ const matchesSku = !sku || op.product?.sku === sku
383
+ const matchesRefCode = !refCode || (op.productDetail as any)?.refCode === refCode
384
+ const matchesBatchId = !batchId || op.batchId === batchId
385
+ const matchesPackingType = !packingType || op.packingType === packingType
386
+ const matchesRefItemId = !refItemId || op.refItemId === refItemId
387
+
388
+ return matchesSku && matchesRefCode && matchesBatchId && matchesPackingType && matchesRefItemId
389
+ }) || null
390
+ )
391
+ }
392
+
393
+ async function removeOrderProducts(tx: EntityManager, orderProductIds: string[], hasOrderInventories: boolean) {
394
+ for (const orderProductId of orderProductIds) {
395
+ const orderProduct = await tx.getRepository(OrderProduct).findOne({
396
+ where: { id: orderProductId },
397
+ relations: ['orderInventories', 'orderInventories.inventory']
398
+ })
399
+
400
+ if (!orderProduct) continue
401
+
402
+ const orderProductEntity = orderProduct as OrderProduct
403
+ const orderInventories = (orderProductEntity.orderInventories || []) as OrderInventory[]
404
+ const releaseQty = orderProductEntity.releaseQty || 0
405
+ const releaseUomValue = orderProductEntity.releaseUomValue || 0
406
+
407
+ if (hasOrderInventories && orderInventories.length > 0) {
408
+ // Release locked quantities from inventories
409
+ for (const orderInventory of orderInventories) {
410
+ const inventory = orderInventory.inventory as Inventory | null
411
+ if (inventory) {
412
+ const oiReleaseQty = orderInventory.releaseQty || 0
413
+ const oiReleaseUomValue = orderInventory.releaseUomValue || 0
414
+ const inventoryEntity = inventory as Inventory
415
+ await tx
416
+ .getRepository(Inventory)
417
+ .createQueryBuilder()
418
+ .update(Inventory)
419
+ .set({
420
+ lockedQty: () => `COALESCE(locked_qty, 0) - ${oiReleaseQty}`,
421
+ lockedUomValue: () => `COALESCE(locked_uom_value, 0) - ${oiReleaseUomValue}`,
422
+ updater: orderProductEntity.updater
423
+ })
424
+ .where('id = :id', { id: inventoryEntity.id })
425
+ .execute()
426
+ }
427
+ }
428
+
429
+ // Delete order inventories
430
+ // First, delete any worksheet details that reference these order inventories
431
+ const oiIds = orderInventories.map(oi => oi.id).filter((id): id is string => !!id)
432
+ if (oiIds.length > 0) {
433
+ // Find and delete worksheet details that reference the order inventories to be deleted
434
+ // Join with OrderInventory to find worksheet details by order inventory IDs
435
+ const worksheetDetailsToDelete: WorksheetDetail[] = await tx
436
+ .getRepository(WorksheetDetail)
437
+ .createQueryBuilder('wsd')
438
+ .innerJoin('wsd.targetInventory', 'oi')
439
+ .where('oi.id IN (:...oiIds)', { oiIds })
440
+ .andWhere('wsd.domain = :domainId', {
441
+ domainId: orderProductEntity.domain?.id || orderProductEntity.domainId
442
+ })
443
+ .getMany()
444
+
445
+ if (worksheetDetailsToDelete.length > 0) {
446
+ const worksheetDetailIds = worksheetDetailsToDelete.map(wsd => wsd.id)
447
+ await tx.getRepository(WorksheetDetail).delete(worksheetDetailIds)
448
+ }
449
+
450
+ // Now delete the order inventories
451
+ await tx.getRepository(OrderInventory).delete(oiIds)
452
+ }
453
+ } else {
454
+ // Update ProductDetailStock unassigned quantities
455
+ const productDetail = orderProductEntity.productDetail as ProductDetail | null
456
+ if (productDetail && productDetail.id) {
457
+ await tx
458
+ .getRepository(ProductDetailStock)
459
+ .createQueryBuilder()
460
+ .update(ProductDetailStock)
461
+ .set({
462
+ unassignedQty: () => `"unassigned_qty" - ${releaseQty}`,
463
+ unassignedUomValue: () => `"unassigned_uom_value" - ${releaseUomValue}`
464
+ })
465
+ .where({ productDetail: productDetail.id })
466
+ .execute()
467
+ }
468
+ }
469
+
470
+ // Delete order product
471
+ await tx.getRepository(OrderProduct).delete(orderProductId)
472
+ }
473
+ }
474
+
475
+ async function updateExistingOrderProduct(
476
+ tx: EntityManager,
477
+ existing: OrderProduct,
478
+ request: any,
479
+ releaseGood: ReleaseGood,
480
+ domain: Domain,
481
+ user: User,
482
+ context: any,
483
+ hasOrderInventories: boolean
484
+ ) {
485
+ const newQty = request.releaseQty !== undefined ? request.releaseQty : request.qty
486
+ if (newQty === undefined) {
487
+ // No quantity change, just update other fields if provided
488
+ const updateData: any = { updater: user }
489
+ if (request.remark !== undefined) updateData.remark = request.remark
490
+ if (request.unitPrice !== undefined) updateData.unitPrice = request.unitPrice
491
+
492
+ await tx.getRepository(OrderProduct).update(existing.id, updateData)
493
+ return
494
+ }
495
+
496
+ if (newQty <= 0) {
497
+ throw new ApiError('E01', 'releaseQty must be greater than 0')
498
+ }
499
+
500
+ const qtyDiff = newQty - existing.releaseQty
501
+ const uomValueDiff = (newQty - existing.releaseQty) * (existing.productDetail?.uomValue || 1)
502
+
503
+ // Validate decimal places for non-decimal products
504
+ if (!existing.product?.isInventoryDecimal && newQty % 1 !== 0) {
505
+ throw new ApiError('E01', `releaseQty must be an integer for product ${existing.product?.sku}`)
506
+ }
507
+
508
+ // Round to 3 decimal places
509
+ const roundedNewQty = existing.product?.isInventoryDecimal ? Math.round(newQty * 1000) / 1000 : Math.round(newQty)
510
+ const roundedNewUomValue = roundedNewQty * (existing.productDetail?.uomValue || 1)
511
+
512
+ if (hasOrderInventories) {
513
+ // Update order inventories
514
+ await updateOrderInventoriesForQtyChange(
515
+ tx,
516
+ existing,
517
+ qtyDiff,
518
+ uomValueDiff,
519
+ roundedNewQty,
520
+ roundedNewUomValue,
521
+ user,
522
+ releaseGood,
523
+ domain
524
+ )
525
+ } else {
526
+ // Update ProductDetailStock
527
+ await tx
528
+ .getRepository(ProductDetailStock)
529
+ .createQueryBuilder()
530
+ .update(ProductDetailStock)
531
+ .set({
532
+ unassignedQty: () => `"unassigned_qty" + ${qtyDiff}`,
533
+ unassignedUomValue: () => `"unassigned_uom_value" + ${uomValueDiff}`
534
+ })
535
+ .where({ productDetail: existing.productDetail.id })
536
+ .execute()
537
+ }
538
+
539
+ // Update order product
540
+ const updateData: any = {
541
+ releaseQty: roundedNewQty,
542
+ releaseUomValue: roundedNewUomValue,
543
+ updater: user
544
+ }
545
+ if (request.remark !== undefined) updateData.remark = request.remark
546
+ if (request.unitPrice !== undefined) updateData.unitPrice = request.unitPrice
547
+
548
+ await tx.getRepository(OrderProduct).update(existing.id, updateData)
549
+ }
550
+
551
+ async function updateOrderInventoriesForQtyChange(
552
+ tx: EntityManager,
553
+ orderProduct: OrderProduct,
554
+ qtyDiff: number,
555
+ uomValueDiff: number,
556
+ newQty: number,
557
+ newUomValue: number,
558
+ user: User,
559
+ releaseGood: ReleaseGood,
560
+ domain: Domain
561
+ ) {
562
+ const existingOrderInventories = (await tx.getRepository(OrderInventory).find({
563
+ where: { orderProduct },
564
+ relations: ['inventory']
565
+ })) as OrderInventory[]
566
+
567
+ if (qtyDiff > 0) {
568
+ // Increase: assign more inventory
569
+ await assignAdditionalInventory(tx, orderProduct, qtyDiff, uomValueDiff, user, releaseGood, domain)
570
+ } else if (qtyDiff < 0) {
571
+ // Decrease: select latest order inventories and deduct from them
572
+ const reductionQty = Math.abs(qtyDiff)
573
+ const reductionUomValue = Math.abs(uomValueDiff)
574
+
575
+ // Sort order inventories by creation date (latest first)
576
+ const sortedOrderInventories = [...existingOrderInventories].sort((a, b) => {
577
+ const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0
578
+ const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0
579
+ return dateB - dateA // Latest first
580
+ })
581
+
582
+ let remainingReductionQty = reductionQty
583
+ let remainingReductionUomValue = reductionUomValue
584
+ const orderInventoriesToDelete: string[] = []
585
+
586
+ for (const oi of sortedOrderInventories) {
587
+ if (remainingReductionQty <= 0 && remainingReductionUomValue <= 0) {
588
+ break
589
+ }
590
+
591
+ const oiReleaseQty = oi.releaseQty || 0
592
+ const oiReleaseUomValue = oi.releaseUomValue || 0
593
+
594
+ // Calculate how much to deduct from this order inventory
595
+ const deductQty = Math.min(remainingReductionQty, oiReleaseQty)
596
+ const deductUomValue = Math.min(remainingReductionUomValue, oiReleaseUomValue)
597
+
598
+ const inventory = oi.inventory as Inventory | null
599
+ if (inventory) {
600
+ // Release locked quantities from inventory
601
+ await tx
602
+ .getRepository(Inventory)
603
+ .createQueryBuilder()
604
+ .update(Inventory)
605
+ .set({
606
+ lockedQty: () => `COALESCE(locked_qty, 0) - ${deductQty}`,
607
+ lockedUomValue: () => `COALESCE(locked_uom_value, 0) - ${deductUomValue}`,
608
+ updater: user
609
+ })
610
+ .where('id = :id', { id: inventory.id })
611
+ .execute()
612
+ }
613
+
614
+ const newOiQty = oiReleaseQty - deductQty
615
+ const newOiUomValue = oiReleaseUomValue - deductUomValue
616
+
617
+ if (newOiQty <= 0 || newOiUomValue <= 0) {
618
+ // Mark this order inventory for deletion
619
+ orderInventoriesToDelete.push(oi.id)
620
+ } else {
621
+ // Update order inventory with remaining quantity
622
+ await tx.getRepository(OrderInventory).update(oi.id, {
623
+ releaseQty: newOiQty,
624
+ releaseUomValue: newOiUomValue,
625
+ updater: user
626
+ })
627
+ }
628
+
629
+ remainingReductionQty -= deductQty
630
+ remainingReductionUomValue -= deductUomValue
631
+ }
632
+
633
+ // Delete order inventories that are fully consumed
634
+ if (orderInventoriesToDelete.length > 0) {
635
+ // Find and delete worksheet details that reference these order inventories
636
+ const worksheetDetailsToDelete: WorksheetDetail[] = await tx
637
+ .getRepository(WorksheetDetail)
638
+ .createQueryBuilder('wsd')
639
+ .innerJoin('wsd.targetInventory', 'oi')
640
+ .where('oi.id IN (:...oiIds)', { oiIds: orderInventoriesToDelete })
641
+ .andWhere('wsd.domain = :domainId', { domainId: domain.id })
642
+ .getMany()
643
+
644
+ if (worksheetDetailsToDelete.length > 0) {
645
+ const worksheetDetailIds = worksheetDetailsToDelete.map(wsd => wsd.id)
646
+ await tx.getRepository(WorksheetDetail).delete(worksheetDetailIds)
647
+ }
648
+
649
+ // Now delete the order inventories
650
+ await tx.getRepository(OrderInventory).delete(orderInventoriesToDelete)
651
+ }
652
+ }
653
+
654
+ // Update order product totals
655
+ const remainingOrderInventories = (await tx.getRepository(OrderInventory).find({
656
+ where: { orderProduct }
657
+ })) as OrderInventory[]
658
+
659
+ const totalReleaseQty = remainingOrderInventories.reduce((sum, oi) => sum + (oi.releaseQty || 0), 0)
660
+ const totalReleaseUomValue = remainingOrderInventories.reduce((sum, oi) => sum + (oi.releaseUomValue || 0), 0)
661
+
662
+ await tx.getRepository(OrderProduct).update(orderProduct.id, {
663
+ releaseQty: totalReleaseQty,
664
+ releaseUomValue: totalReleaseUomValue,
665
+ updater: user
666
+ })
667
+ }
668
+
669
+ async function assignAdditionalInventory(
670
+ tx: EntityManager,
671
+ orderProduct: OrderProduct,
672
+ qtyDiff: number,
673
+ uomValueDiff: number,
674
+ user: User,
675
+ releaseGood: ReleaseGood,
676
+ domain: Domain
677
+ ) {
678
+ // Get customer bizplace from releaseGood (it's already loaded)
679
+ // If not loaded, fetch it using bizplaceId
680
+ let customerBizplace = releaseGood.bizplace as Bizplace | undefined
681
+ if (!customerBizplace && releaseGood.bizplaceId) {
682
+ customerBizplace = await tx.getRepository(Bizplace).findOne({
683
+ where: { id: releaseGood.bizplaceId }
684
+ })
685
+ }
686
+ if (!customerBizplace) {
687
+ throw new ApiError('E04', 'Customer bizplace not found for release order')
688
+ }
689
+
690
+ // Get release shelf life override from contact point (deliverTo)
691
+ let releaseShelfLifeOverride: number | null = null
692
+ if (releaseGood.deliverToId) {
693
+ const deliverToContactPoint: ContactPoint | null = await tx
694
+ .getRepository(ContactPoint)
695
+ .findOne({ where: { id: releaseGood.deliverToId } })
696
+ if (deliverToContactPoint?.releaseShelfLife != null && deliverToContactPoint.releaseShelfLife !== 0) {
697
+ releaseShelfLifeOverride = deliverToContactPoint.releaseShelfLife
698
+ }
699
+ }
700
+
701
+ // Get picking product setting
702
+ const pickingProductSetting: Setting = await tx.getRepository(Setting).findOne({
703
+ where: { domain, name: 'rule-for-picking-product' }
704
+ })
705
+
706
+ // Determine orderInventory status based on releaseGood status
707
+ let orderInventoryStatus: string
708
+ switch (releaseGood.status) {
709
+ case ORDER_STATUS.PENDING:
710
+ orderInventoryStatus = ORDER_INVENTORY_STATUS.PENDING
711
+ break
712
+ case ORDER_STATUS.PENDING_RECEIVE:
713
+ orderInventoryStatus = ORDER_INVENTORY_STATUS.PENDING_RECEIVE
714
+ break
715
+ case ORDER_STATUS.PENDING_WORKSHEET:
716
+ orderInventoryStatus = ORDER_INVENTORY_STATUS.PENDING_WORKSHEET
717
+ break
718
+ case ORDER_STATUS.READY_TO_PICK:
719
+ orderInventoryStatus = ORDER_INVENTORY_STATUS.READY_TO_PICK
720
+ break
721
+ default:
722
+ // Default to PENDING_WORKSHEET for other statuses
723
+ orderInventoryStatus = ORDER_INVENTORY_STATUS.PENDING_WORKSHEET
724
+ }
725
+
726
+ // Ensure product is loaded
727
+ if (!orderProduct.product) {
728
+ orderProduct.product = await tx.getRepository(Product).findOne({
729
+ where: { id: orderProduct.productId }
730
+ })
731
+ }
732
+
733
+ const product = orderProduct.product
734
+ if (!product) {
735
+ throw new ApiError('E04', 'Product not found for order product')
736
+ }
737
+
738
+ // Determine picking strategy sortings
739
+ let sortings: any = []
740
+ switch (product.pickingStrategy) {
741
+ case 'LIFO':
742
+ sortings.push({ name: 'iv.created_at', desc: true })
743
+ if (pickingProductSetting?.value) {
744
+ for (const [key, value] of Object.entries(JSON.parse(pickingProductSetting.value))) {
745
+ sortings.push({ name: `loc.${key}`, desc: value == 'DESC' ? true : false })
746
+ }
747
+ } else {
748
+ sortings.push({ name: 'loc.name', desc: false }, { name: 'iv.created_at', desc: false })
749
+ }
750
+ break
751
+ case 'FEFO':
752
+ sortings.push({ name: 'iv.expiration_date', desc: false }, { name: 'iv.created_at', desc: false })
753
+ if (pickingProductSetting?.value) {
754
+ for (const [key, value] of Object.entries(JSON.parse(pickingProductSetting.value))) {
755
+ sortings.push({ name: `loc.${key}`, desc: value == 'DESC' ? true : false })
756
+ }
757
+ } else {
758
+ sortings.push({ name: 'loc.name', desc: false }, { name: 'iv.created_at', desc: false })
759
+ }
760
+ break
761
+ case 'FMFO':
762
+ sortings.push({ name: 'iv.manufacture_date', desc: false }, { name: 'iv.created_at', desc: false })
763
+ if (pickingProductSetting?.value) {
764
+ for (const [key, value] of Object.entries(JSON.parse(pickingProductSetting.value))) {
765
+ sortings.push({ name: `loc.${key}`, desc: value == 'DESC' ? true : false })
766
+ }
767
+ } else {
768
+ sortings.push({ name: 'loc.name', desc: false }, { name: 'iv.created_at', desc: false })
769
+ }
770
+ break
771
+ case 'LOCATION':
772
+ if (pickingProductSetting?.value) {
773
+ for (const [key, value] of Object.entries(JSON.parse(pickingProductSetting.value))) {
774
+ sortings.push({ name: `loc.${key}`, desc: value == 'DESC' ? true : false })
775
+ }
776
+ } else {
777
+ sortings.push({ name: 'loc.name', desc: false }, { name: 'iv.created_at', desc: false })
778
+ }
779
+ break
780
+ // Every other case includes 'FIFO' will be applicable for this case
781
+ default:
782
+ sortings.push({ name: 'iv.created_at', desc: false })
783
+ if (pickingProductSetting?.value) {
784
+ for (const [key, value] of Object.entries(JSON.parse(pickingProductSetting.value))) {
785
+ sortings.push({ name: `loc.${key}`, desc: value == 'DESC' ? true : false })
786
+ }
787
+ } else {
788
+ sortings.push({ name: 'loc.name', desc: false })
789
+ sortings.push({ name: 'iv.pallet_id', desc: false })
790
+ }
791
+ break
792
+ }
793
+
794
+ let queryFilters: any[] = []
795
+ let queryStrings = queryFilters.reduce(
796
+ (acc, itm, idx, arr) => {
797
+ acc.values.push(itm.filters)
798
+
799
+ switch (itm?.operator) {
800
+ case 'notin':
801
+ case 'in':
802
+ acc.query.push(
803
+ `${itm.query} ${itm.operator == 'notin' ? 'NOT IN' : 'IN'} (${itm.filters
804
+ .map((itm, idx) => {
805
+ return `$${acc.values.length + 1}`
806
+ })
807
+ .join(',')})`
808
+ )
809
+ break
810
+
811
+ default:
812
+ acc.query.push(`${itm.query} ${itm?.operator ? itm.operator : '='} $${acc.values.length + 1}`)
813
+ break
814
+ }
815
+ acc.query.push(`${itm.query} ${itm?.operator ? itm.operator : '='} $${acc.values.length + 1}`)
816
+ return acc
817
+ },
818
+ {
819
+ query: [],
820
+ values: []
821
+ }
822
+ )
823
+
824
+ let sortQuery = sortings
825
+ .map(itm => {
826
+ return `${itm.name} ${itm.desc ? 'DESC' : 'ASC'}`
827
+ })
828
+ .join(`,`)
829
+
830
+ // Ensure productDetail is loaded for uom
831
+ if (!orderProduct.productDetail) {
832
+ orderProduct.productDetail = await tx.getRepository(ProductDetail).findOne({
833
+ where: { id: orderProduct.productDetailId }
834
+ })
835
+ }
836
+ const productDetail = orderProduct.productDetail as ProductDetail | null
837
+ const uom = (productDetail && (productDetail as any).uom) || product.uom || 'UN'
838
+
839
+ let params = [
840
+ user.id,
841
+ domain.id,
842
+ customerBizplace.id,
843
+ orderProduct.packingType,
844
+ orderProduct.packingSize,
845
+ product.id,
846
+ INVENTORY_STATUS.STORED,
847
+ LOCATION_TYPE.QUARANTINE,
848
+ LOCATION_TYPE.RESERVE,
849
+ LOCATION_TYPE.STORAGE
850
+ ]
851
+
852
+ let batchId = orderProduct.batchId && orderProduct.batchId !== '-' ? String(orderProduct.batchId).trim() : null
853
+ if (batchId) params.push(batchId)
854
+ params = [...params, ...queryStrings.values]
855
+
856
+ // Handle warehouse code filtering
857
+ const warehouseCode = (orderProduct as any).warehouseCode
858
+ const warehouseNameFilter = warehouseCode ? `AND w.name = $${params.length + 1}` : ''
859
+ if (warehouseCode) {
860
+ params.push(String(warehouseCode).trim())
861
+ }
862
+
863
+ // Add release shelf life override parameter
864
+ const releaseShelfLifeParamIndex = params.length + 1
865
+ params.push(releaseShelfLifeOverride)
866
+
867
+ // Build the sophisticated SQL query with window functions
868
+ let query = `
869
+ update inventories tgt set locked_qty = coalesce(locked_qty,0) + src.reserve_qty,
870
+ locked_uom_value = coalesce(locked_uom_value,0) + src.reserve_uom_value,
871
+ updated_at = NOW(),
872
+ updater_id = $1
873
+ from (
874
+ select "id", "pallet_id","carton_id", "batch_id", "batch_id_ref", "unit", "uom", "packing_type", "packing_size", "manufacture_year",
875
+ "reserve_qty", (("uom_value"/"qty") * "reserve_qty") as "reserve_uom_value" from (
876
+ select "sort_seq", "id", "pallet_id", "batch_id", "batch_id_ref", "unit", "uom", "packing_type", "packing_size", "manufacture_year", "carton_id", "uom_value", "locked_uom_value", "qty", "locked_qty", "created_at",
877
+ "release_qty", "release_uom_value", lock_amount,
878
+ case when "lock_amount" < 0 then "available_qty" else "available_qty" - "lock_amount" end as "reserve_qty"
879
+ from (
880
+ select *,
881
+ (
882
+ case when (qty - greatest(locked_qty, 0) - greatest(unassigned_qty, 0)) < 0 then 0
883
+ else qty - greatest(locked_qty, 0) - greatest(unassigned_qty, 0) end
884
+ ) as available_qty,
885
+ sum(qty - locked_qty - release_qty - unassigned_qty) over (order by sort_seq asc rows between unbounded preceding and current row) as lock_amount
886
+ from (
887
+ SELECT 0 as sort_seq, null as id, null as pallet_id, null as batch_id, null as batch_id_ref,
888
+ null as unit, '${uom}' as uom, '${orderProduct.packingType}' as packing_type, '${
889
+ orderProduct.packingSize
890
+ }' as packing_size,
891
+ null as manufacture_year, null as carton_id, 0 as uom_value, 0 as locked_uom_value, 0 as qty, 0 as locked_qty, 0 as unassigned_uom_value, 0 as unassigned_qty, null as created_at,
892
+ ${qtyDiff}::numeric as release_qty, ${uomValueDiff}::numeric as release_uom_value
893
+ UNION
894
+ (
895
+ SELECT ROW_NUMBER() OVER(PARTITION BY iv.domain_id ORDER BY wiar.rank ${
896
+ sortQuery ? ', ' + sortQuery : ''
897
+ }) as sort_seq,
898
+ iv.id, iv.pallet_id, iv.batch_id, iv.batch_id_ref,
899
+ iv.unit, iv.uom, iv.packing_type, iv.packing_size,
900
+ iv.manufacture_year, iv.carton_id, iv.uom_value,
901
+ coalesce(iv.locked_uom_value,0) as locked_uom_value, iv.qty, greatest(coalesce(iv.locked_qty,0),0) as locked_qty, coalesce(pds.unassigned_uom_value,0) as unassigned_uom_value, greatest(coalesce(pds.unassigned_qty,0),0) as unassigned_qty,
902
+ iv.created_at, 0 as release_qty, 0 as release_uom_value
903
+ FROM "inventories" "iv"
904
+ LEFT JOIN "product_detail_stocks" "pds" ON "pds"."product_detail_id" = "iv"."product_detail_id"
905
+ INNER JOIN "locations" "loc" ON "loc"."id"="iv"."location_id"
906
+ INNER JOIN "warehouses" "w" ON "w"."id" = "loc"."warehouse_id"
907
+ INNER JOIN "products" "p" ON "p"."id"="iv"."product_id"
908
+ INNER JOIN "warehouse_inventory_assignment_rankings" "wiar" ON "wiar"."location_type"="loc"."type"
909
+ WHERE case when coalesce("iv"."locked_qty",0) < 0 then 0 else ("iv"."qty" - coalesce("iv"."locked_qty",0)) end > 0 AND
910
+ "iv"."domain_id" = $2 AND "iv"."bizplace_id" = $3 AND "iv"."packing_type" = $4 AND "iv"."packing_size" = $5 AND "iv"."product_id" = $6 AND "iv"."status" = $7 AND "loc"."type" NOT IN ($8, $9, $10)
911
+ AND ${batchId ? `"iv"."batch_id" = $11` : `1=1`}
912
+ ${warehouseNameFilter}
913
+ AND "iv"."obsolete" = false AND (
914
+ "iv"."expiration_date" IS NULL
915
+ OR
916
+ CASE
917
+ WHEN $${releaseShelfLifeParamIndex}::integer IS NOT NULL AND $${releaseShelfLifeParamIndex}::integer > 0 THEN
918
+ CURRENT_DATE < ("iv"."expiration_date" - $${releaseShelfLifeParamIndex}::integer)
919
+ WHEN "p"."min_outbound_shelf_life" IS NOT NULL AND "p"."min_outbound_shelf_life" > 0 THEN
920
+ CURRENT_DATE < ("iv"."expiration_date" - "p"."min_outbound_shelf_life")
921
+ ELSE
922
+ TRUE
923
+ END
924
+ )
925
+ ${queryStrings.query.length > 0 ? `AND ${queryStrings.query.join(' AND ')}` : ''}
926
+ ORDER BY wiar.rank ${sortQuery ? ', ' + sortQuery : ''}
927
+ )
928
+ ) dt1
929
+ ) dt2 where case when "lock_amount" < 0 then "available_qty" else "available_qty" - "lock_amount" end > 0
930
+ ) dt3 where sort_seq > 0
931
+ ) src where src.id = tgt.id
932
+ returning
933
+ tgt."id", tgt."qty", tgt."pallet_id", tgt."carton_id", tgt."batch_id", tgt."batch_id_ref", tgt."unit",
934
+ tgt."uom", tgt."packing_type", tgt."packing_size", tgt."manufacture_year",
935
+ tgt."locked_qty", tgt."uom_value", tgt."locked_uom_value", src."reserve_qty", src."reserve_uom_value";`
936
+
937
+ let updatedInventories = await tx.getRepository(Inventory).query(query, params)
938
+
939
+ let totalAssigned =
940
+ updatedInventories[0]?.reduce((acc: number, inv: any) => {
941
+ return acc + inv.reserve_qty
942
+ }, 0) || 0
943
+
944
+ // For non-decimal products, round the values before comparison
945
+ let roundedQtyDiff = qtyDiff
946
+ if (!product.isInventoryDecimal) {
947
+ roundedQtyDiff = Math.round(qtyDiff)
948
+ totalAssigned = Math.round(totalAssigned)
949
+ } else {
950
+ // For decimal products, round to 3 decimal places
951
+ roundedQtyDiff = Math.round(qtyDiff * 1000) / 1000
952
+ totalAssigned = Math.round(totalAssigned * 1000) / 1000
953
+ }
954
+
955
+ if (Math.abs(roundedQtyDiff - totalAssigned) > 0.001) {
956
+ // Using small epsilon for float comparison
957
+ throw new ApiError('E01', 'INSUFFICIENT_STOCK')
958
+ }
959
+
960
+ // Create order inventory records for each assigned inventory
961
+ if (updatedInventories[0] && updatedInventories[0].length > 0) {
962
+ // Ensure releaseGood.id is available (required for TypeORM relation)
963
+ if (!releaseGood.id) {
964
+ throw new ApiError('E04', 'Release good ID is required for order inventory')
965
+ }
966
+
967
+ // Ensure orderProduct.id is available (required for TypeORM relation)
968
+ if (!orderProduct.id) {
969
+ throw new ApiError('E04', 'Order product ID is required for order inventory')
970
+ }
971
+
972
+ // Get existing order inventories for this order product to check for duplicates
973
+ const existingOrderInventories: OrderInventory[] = await tx.getRepository(OrderInventory).find({
974
+ where: { orderProduct },
975
+ relations: ['inventory']
976
+ })
977
+
978
+ // Check if worksheets exist for this release good
979
+ const existingWorksheets: Worksheet[] = await tx.getRepository(Worksheet).find({
980
+ where: {
981
+ releaseGood: { id: releaseGood.id },
982
+ type: WORKSHEET_TYPE.PICKING,
983
+ status: In([WORKSHEET_STATUS.DEACTIVATED])
984
+ },
985
+ relations: ['bizplace']
986
+ })
987
+
988
+ const newOrderInventories: OrderInventory[] = []
989
+
990
+ for (const inv of updatedInventories[0]) {
991
+ // Check if an order inventory already exists for this inventory ID
992
+ const existingOI = existingOrderInventories.find(
993
+ (oi: OrderInventory) => oi.inventory && (oi.inventory as Inventory).id === inv.id
994
+ )
995
+
996
+ let savedOrderInventory: OrderInventory
997
+
998
+ if (existingOI) {
999
+ // Update existing order inventory
1000
+ const updatedReleaseQty = (existingOI.releaseQty || 0) + inv.reserve_qty
1001
+ const updatedReleaseUomValue = (existingOI.releaseUomValue || 0) + inv.reserve_uom_value
1002
+
1003
+ await tx.getRepository(OrderInventory).update(existingOI.id, {
1004
+ releaseQty: updatedReleaseQty,
1005
+ releaseUomValue: updatedReleaseUomValue,
1006
+ updater: user
1007
+ })
1008
+
1009
+ // Reload to get updated entity
1010
+ savedOrderInventory = (await tx.getRepository(OrderInventory).findOne({
1011
+ where: { id: existingOI.id },
1012
+ relations: ['inventory']
1013
+ })) as OrderInventory
1014
+ } else {
1015
+ // Create new order inventory
1016
+ savedOrderInventory = await tx.getRepository(OrderInventory).save(
1017
+ Object.assign(new OrderInventory(), {
1018
+ name: uuidv4(),
1019
+ domain: domain, // Use passed domain parameter
1020
+ bizplace: customerBizplace, // Use customerBizplace from releaseGood
1021
+ releaseGood: releaseGood,
1022
+ releaseGoodId: releaseGood.id, // Explicitly set to ensure foreign key is saved
1023
+ orderProduct: orderProduct,
1024
+ orderProductId: orderProduct.id, // Explicitly set to ensure foreign key is saved
1025
+ product: product,
1026
+ productDetail: productDetail,
1027
+ inventory: { id: inv.id } as Inventory,
1028
+ packingType: inv.packing_type,
1029
+ packingSize: inv.packing_size,
1030
+ batchId: inv.batch_id,
1031
+ releaseQty: inv.reserve_qty,
1032
+ releaseUomValue: inv.reserve_uom_value,
1033
+ uom: inv.uom,
1034
+ type: ORDER_TYPES.RELEASE_OF_GOODS,
1035
+ status: orderInventoryStatus,
1036
+ creator: user,
1037
+ updater: user
1038
+ })
1039
+ )
1040
+ newOrderInventories.push(savedOrderInventory)
1041
+ }
1042
+ }
1043
+
1044
+ // Create worksheet details for newly created order inventories if worksheets exist
1045
+ if (existingWorksheets.length > 0 && newOrderInventories.length > 0) {
1046
+ for (const worksheet of existingWorksheets) {
1047
+ const worksheetDetails = newOrderInventories.map((oi: OrderInventory) => ({
1048
+ domain: domain,
1049
+ bizplace: (worksheet as Worksheet).bizplace || customerBizplace,
1050
+ worksheet: worksheet,
1051
+ name: WorksheetNoGenerator.pickingDetail(),
1052
+ type: WORKSHEET_TYPE.PICKING,
1053
+ status: WORKSHEET_STATUS.DEACTIVATED,
1054
+ targetInventory: oi,
1055
+ creator: user,
1056
+ updater: user
1057
+ }))
1058
+
1059
+ await tx.getRepository(WorksheetDetail).save(worksheetDetails)
1060
+ }
1061
+ }
1062
+ }
1063
+ }
1064
+
1065
+ async function addNewOrderProduct(
1066
+ tx: EntityManager,
1067
+ orderProductReq: any,
1068
+ releaseGood: ReleaseGood,
1069
+ domain: Domain,
1070
+ user: User,
1071
+ context: any,
1072
+ hasOrderInventories: boolean
1073
+ ) {
1074
+ const sku = orderProductReq.product?.sku || orderProductReq.sku
1075
+ const refCode = orderProductReq.product?.refCode || orderProductReq.refCode
1076
+ const packingType = orderProductReq.packingType
1077
+ const packingSize = orderProductReq.packingSize
1078
+ const releaseQty = orderProductReq.releaseQty !== undefined ? orderProductReq.releaseQty : orderProductReq.qty
1079
+ const refItemId = orderProductReq.product?.refItemId || orderProductReq.refItemId || null
1080
+
1081
+ if (!sku && !refCode) {
1082
+ throw new ApiError('E01', 'sku or refCode is required for new items')
1083
+ }
1084
+
1085
+ if (!releaseQty || releaseQty <= 0) {
1086
+ throw new ApiError('E01', 'releaseQty must be greater than 0')
1087
+ }
1088
+
1089
+ // Ensure releaseGood has an ID (required for TypeORM relation)
1090
+ if (!releaseGood.id) {
1091
+ throw new ApiError('E04', 'Release good ID is required')
1092
+ }
1093
+
1094
+ // Get company bizplace for product lookup
1095
+ const customerCompanyBizplace: Bizplace = await getCompanyBizplace(null, null, releaseGood.bizplaceId, tx)
1096
+
1097
+ // Find product detail
1098
+ const qb: SelectQueryBuilder<ProductDetail> = tx
1099
+ .getRepository(ProductDetail)
1100
+ .createQueryBuilder('pd')
1101
+ .innerJoinAndSelect('pd.product', 'prod')
1102
+ .where('prod.bizplace_id = :bizplaceId', { bizplaceId: customerCompanyBizplace.id })
1103
+ .andWhere('pd.deleted_at IS NULL')
1104
+
1105
+ if (refCode) {
1106
+ qb.andWhere('pd.ref_code = :refCode', { refCode })
1107
+ }
1108
+
1109
+ if (sku) {
1110
+ qb.andWhere('prod.sku = :sku', { sku })
1111
+ }
1112
+
1113
+ if (packingType) {
1114
+ qb.andWhere('pd.packing_type = :packingType', { packingType })
1115
+ }
1116
+
1117
+ if (packingSize) {
1118
+ qb.andWhere('pd.packing_size = :packingSize', { packingSize })
1119
+ }
1120
+
1121
+ if (!refCode && !packingType && !packingSize) {
1122
+ qb.andWhere('pd.is_default = :isDefault', { isDefault: true })
1123
+ }
1124
+
1125
+ const productDetail: ProductDetail = await qb.getOne()
1126
+
1127
+ if (!productDetail) {
1128
+ throw new ApiError('E04', `Product not found: ${sku || refCode}`)
1129
+ }
1130
+
1131
+ // Validate decimal places
1132
+ if (!productDetail.product.isInventoryDecimal && releaseQty % 1 !== 0) {
1133
+ throw new ApiError('E01', `releaseQty must be an integer for product ${sku || refCode}`)
1134
+ }
1135
+
1136
+ // Round to 3 decimal places
1137
+ const roundedReleaseQty = productDetail.product.isInventoryDecimal
1138
+ ? Math.round(releaseQty * 1000) / 1000
1139
+ : Math.round(releaseQty)
1140
+ const releaseUomValue = roundedReleaseQty * (productDetail.uomValue || 1)
1141
+
1142
+ // Ensure bizplace is loaded - fetch it if not already loaded
1143
+ let bizplace = releaseGood.bizplace
1144
+ if (!bizplace && releaseGood.bizplaceId) {
1145
+ bizplace = await tx.getRepository(Bizplace).findOne({ where: { id: releaseGood.bizplaceId } })
1146
+ if (!bizplace) {
1147
+ throw new ApiError('E04', 'Bizplace not found for release order')
1148
+ }
1149
+ } else if (!bizplace) {
1150
+ throw new ApiError('E04', 'Bizplace is required for order product')
1151
+ }
1152
+
1153
+ const repo = tx.getRepository(OrderProduct)
1154
+
1155
+ const created = repo.create({
1156
+ name: `OP-` + uuidv4(),
1157
+
1158
+ // Use relation stubs (IDs only) to force FK persistence
1159
+ domain: { id: domain.id } as any,
1160
+ bizplace: { id: (bizplace as any).id } as any,
1161
+ releaseGood: { id: releaseGood.id } as any,
1162
+ releaseGoodId: releaseGood.id, // Explicitly set the column value
1163
+ product: { id: (productDetail.product as any).id } as any,
1164
+ productDetail: { id: (productDetail as any).id } as any,
1165
+ packingType: productDetail.packingType,
1166
+ packingSize: packingSize || productDetail.packingSize,
1167
+ batchId: orderProductReq.batchId || '',
1168
+ refItemId: refItemId,
1169
+ releaseQty: roundedReleaseQty,
1170
+ releaseUomValue: releaseUomValue,
1171
+ uom: productDetail.uom,
1172
+ uomValue: productDetail.uomValue,
1173
+ packQty: 0,
1174
+ actualPackQty: 0,
1175
+ palletQty: 0,
1176
+ actualPalletQty: 0,
1177
+ type: ORDER_TYPES.RELEASE_OF_GOODS,
1178
+ status: ORDER_PRODUCT_STATUS.ASSIGNED,
1179
+ unitPrice: orderProductReq.unitPrice || null,
1180
+ remark: orderProductReq.remark || null,
1181
+ creator: user,
1182
+ updater: user
1183
+ })
1184
+
1185
+ const newOrderProduct = await repo.save(created)
1186
+
1187
+ if (hasOrderInventories) {
1188
+ // Assign inventory - pass releaseGood so status can be determined
1189
+ await assignAdditionalInventory(tx, newOrderProduct, roundedReleaseQty, releaseUomValue, user, releaseGood, domain)
1190
+ } else {
1191
+ // Update ProductDetailStock
1192
+ await tx
1193
+ .getRepository(ProductDetailStock)
1194
+ .createQueryBuilder()
1195
+ .update(ProductDetailStock)
1196
+ .set({
1197
+ unassignedQty: () => `"unassigned_qty" + ${roundedReleaseQty}`,
1198
+ unassignedUomValue: () => `"unassigned_uom_value" + ${releaseUomValue}`
1199
+ })
1200
+ .where({ productDetail: productDetail.id })
1201
+ .execute()
1202
+ }
110
1203
  }