@things-factory/operato-hub 4.3.744 → 4.3.746

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