diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt index d2805fb..e0da6d2 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt @@ -89,6 +89,11 @@ import java.io.FileNotFoundException import java.util.Locale import com.ffii.fpsms.modules.deliveryOrder.service.DeliveryOrderService import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.TransactionDefinition +import org.springframework.transaction.support.TransactionSynchronization +import org.springframework.transaction.support.TransactionSynchronizationManager +import org.springframework.transaction.support.TransactionTemplate /** * DO workbench: pick execution and related operations (v1: [scanPick]). */ @@ -122,6 +127,7 @@ open class DoWorkbenchMainService( private val joPickOrderRepository: JoPickOrderRepository, private val printerService: PrinterService, private val itemsRepository: ItemsRepository, + private val transactionManager: PlatformTransactionManager, ) { @PersistenceContext private lateinit var entityManager: EntityManager @@ -589,83 +595,30 @@ val saveSolMs = lapMs() val pickOrderId = pol.pickOrder?.id val poType = pol.pickOrder?.type -var rebuildMs = 0L -var ensureMs = 0L -var polPartialMs = 0L -var postMs = 0L val effectiveExcludeWarehouseCodes = when (poType) { PickOrderType.JOB_ORDER -> request.excludeWarehouseCodes ?: emptyList() else -> request.excludeWarehouseCodes // null → DO 走 default;有傳則整份取代 default } -if (pickOrderId != null) { - if (hasExplicitQty) { - rebuildMs = measureTimeMillis { - if (explicitRemainder > BigDecimal.ZERO) { - suggestedPickLotWorkbenchService.setNoHoldSuggestionsForPickOrderLineNextSingleLot( - pickOrderLineId = polId, - targetQty = explicitRemainder, - storeId = request.storeId, - excludeInventoryLotLineId = scannedIll.id, - excludeWarehouseCodes = effectiveExcludeWarehouseCodes, - ) - } else { - suggestedPickLotWorkbenchService.setNoHoldSuggestionsForPickOrderLineExactQty( - pickOrderLineId = polId, - targetQty = BigDecimal.ZERO, - excludeWarehouseCodes = effectiveExcludeWarehouseCodes, - ) - } - } - ensureMs = measureTimeMillis { - if (explicitRemainder > BigDecimal.ZERO) { - stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderLineNoHold(polId, request.userId) - } else { - val allSolEntities = - stockOutLIneRepository.findAllByPickOrderLineIdInAndDeletedFalse(listOf(polId)) - val toClose = allSolEntities.filter { it.id != sol.id && !isWorkbenchSolEndStatus(it.status) } - if (toClose.isNotEmpty()) { - toClose.forEach { s -> - s.status = StockOutLineStatus.COMPLETE.status - if (s.startTime == null) s.startTime = LocalDateTime.now() - s.endTime = LocalDateTime.now() - } - stockOutLIneRepository.saveAll(toClose) - stockOutLIneRepository.flush() - } - } - } - } else { - rebuildMs = measureTimeMillis { - suggestedPickLotWorkbenchService.rebuildNoHoldSuggestionsForPickOrderLine( - pickOrderLineId = polId, - storeId = request.storeId, - excludeWarehouseCodes = effectiveExcludeWarehouseCodes, - ) - } - ensureMs = measureTimeMillis { - stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderLineNoHold(polId, request.userId) - } - } - - if (effectiveLotExhaustedSplit) { - polPartialMs = measureTimeMillis { - val polEntity = pickOrderLineRepository.findById(polId).orElse(null) - if (polEntity != null) { - polEntity.status = PickOrderLineStatus.PARTIALLY_COMPLETE - pickOrderLineRepository.save(polEntity) - } - } +val ledgerMs = measureTimeMillis { createWorkbenchPickLedger(sol, effectiveDelta) } +registerAfterCommit { + runInNewTransaction { + runWorkbenchPickDeferredFollowUps( + solId = sol.id!!, + polId = polId, + pickOrderId = pickOrderId, + itemId = itemId, + userId = request.userId, + hasExplicitQty = hasExplicitQty, + explicitRemainder = explicitRemainder, + scannedIllId = scannedIll.id, + requestStoreId = request.storeId, + effectiveExcludeWarehouseCodes = effectiveExcludeWarehouseCodes, + effectiveLotExhaustedSplit = effectiveLotExhaustedSplit, + effectiveDelta = effectiveDelta, + ) } - - updateJoPickOrderHandledByIfJobOrder( - pickOrderId = pickOrderId, - itemId = itemId, - userId = request.userId, - ) } -postMs = measureTimeMillis { postWorkbenchPickSideEffects(sol, effectiveDelta) } - val mapFetchT0 = System.nanoTime() val mapped = stockOutLIneRepository.findStockOutLineInfoById(sol.id!!) val mapFetchMs = (System.nanoTime() - mapFetchT0) / 1_000_000 @@ -673,15 +626,12 @@ val mapFetchMs = (System.nanoTime() - mapFetchT0) / 1_000_000 val totalMs = (System.nanoTime() - wall0) / 1_000_000 /* log.info( - "workbench scanPick timing (ms): total={} prep={} outbound={} saveSol={} rebuildSpl={} ensureSol={} polPartial={} postEffects={} mapFetch={} lotSplit={} solId={} polId={} poId={}", + "workbench scanPick timing (ms): total={} prep={} outbound={} saveSol={} ledger={} mapFetch={} lotSplit={} solId={} polId={} poId={}", totalMs, prepMs, outboundMs, saveSolMs, - rebuildMs, - ensureMs, - polPartialMs, - postMs, + ledgerMs, mapFetchMs, effectiveLotExhaustedSplit, sol.id, @@ -1952,16 +1902,152 @@ return MessageResponse( } checkWorkbenchPickOrderLineCompleted(polId, solsForPol) val pickOrder = pol.pickOrder ?: return - val poId = pickOrder.id ?: return - val allLines = pickOrderLineRepository.findAllByPickOrderIdAndDeletedFalse(poId) - val allCompleted = allLines.isNotEmpty() && allLines.all { it.status == PickOrderLineStatus.COMPLETED } + val poId = pol.pickOrder?.id ?: return + val freshPo = pickOrderRepository.findById(poId).orElse(null) ?: return + val total = freshPo.totalLines ?: 0 + val completed = freshPo.submittedLines ?: 0 + val allCompleted = total > 0 && completed >= total if (allCompleted) { completePickOrderWithRetry(poId) tryCompleteDeliveryOrderPickOrderTicketCompleted(poId) } } + private fun registerAfterCommit(action: () -> Unit) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + action() + return + } + TransactionSynchronizationManager.registerSynchronization( + object : TransactionSynchronization { + override fun afterCommit() { + action() + } + }, + ) + } + + private fun runInNewTransaction(action: () -> Unit) { + val txTemplate = TransactionTemplate(transactionManager).apply { + propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW + } + txTemplate.executeWithoutResult { + action() + } + } - private fun postWorkbenchPickSideEffects(savedStockOutLine: StockOutLine, deltaQty: BigDecimal) { + private fun runWorkbenchPickDeferredFollowUps( + solId: Long, + polId: Long, + pickOrderId: Long?, + itemId: Long, + userId: Long, + hasExplicitQty: Boolean, + explicitRemainder: BigDecimal, + scannedIllId: Long?, + requestStoreId: String?, + effectiveExcludeWarehouseCodes: List?, + effectiveLotExhaustedSplit: Boolean, + effectiveDelta: BigDecimal, + ) { + val deferredStart = System.nanoTime() + var rebuildMs = 0L + var ensureMs = 0L + var polPartialMs = 0L + var joHandledByMs = 0L + var postMs = 0L + try { + if (pickOrderId != null) { + if (hasExplicitQty) { + rebuildMs = measureTimeMillis { + if (explicitRemainder > BigDecimal.ZERO) { + suggestedPickLotWorkbenchService.setNoHoldSuggestionsForPickOrderLineNextSingleLot( + pickOrderLineId = polId, + targetQty = explicitRemainder, + storeId = requestStoreId, + excludeInventoryLotLineId = scannedIllId, + excludeWarehouseCodes = effectiveExcludeWarehouseCodes, + ) + } else { + suggestedPickLotWorkbenchService.setNoHoldSuggestionsForPickOrderLineExactQty( + pickOrderLineId = polId, + targetQty = BigDecimal.ZERO, + excludeWarehouseCodes = effectiveExcludeWarehouseCodes, + ) + } + } + ensureMs = measureTimeMillis { + if (explicitRemainder > BigDecimal.ZERO) { + stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderLineNoHold(polId, userId) + } else { + val allSolEntities = + stockOutLIneRepository.findAllByPickOrderLineIdInAndDeletedFalse(listOf(polId)) + val toClose = allSolEntities.filter { it.id != solId && !isWorkbenchSolEndStatus(it.status) } + if (toClose.isNotEmpty()) { + toClose.forEach { s -> + s.status = StockOutLineStatus.COMPLETE.status + if (s.startTime == null) s.startTime = LocalDateTime.now() + s.endTime = LocalDateTime.now() + } + stockOutLIneRepository.saveAll(toClose) + stockOutLIneRepository.flush() + } + } + } + } else { + rebuildMs = measureTimeMillis { + suggestedPickLotWorkbenchService.rebuildNoHoldSuggestionsForPickOrderLine( + pickOrderLineId = polId, + storeId = requestStoreId, + excludeWarehouseCodes = effectiveExcludeWarehouseCodes, + ) + } + ensureMs = measureTimeMillis { + stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderLineNoHold(polId, userId) + } + } + + if (effectiveLotExhaustedSplit) { + polPartialMs = measureTimeMillis { + val polEntity = pickOrderLineRepository.findById(polId).orElse(null) + if (polEntity != null) { + polEntity.status = PickOrderLineStatus.PARTIALLY_COMPLETE + pickOrderLineRepository.save(polEntity) + } + } + } + + joHandledByMs = measureTimeMillis { + updateJoPickOrderHandledByIfJobOrder( + pickOrderId = pickOrderId, + itemId = itemId, + userId = userId, + ) + } + } + val savedSol = stockOutLIneRepository.findById(solId).orElse(null) + if (savedSol != null) { + postMs = measureTimeMillis { + postWorkbenchPickSideEffects(savedSol, effectiveDelta, createLedger = false) + } + } + } catch (e: Exception) { + log.error("WORKBENCH_DEFERRED_PICK_FAILED solId={} polId={} msg={}", solId, polId, e.message, e) + } finally { + val totalMs = (System.nanoTime() - deferredStart) / 1_000_000 + log.info( + "WORKBENCH_DEFERRED_PICK_TRACE solId={} polId={} totalMs={} rebuildMs={} ensureMs={} polPartialMs={} joHandledByMs={} postMs={}", + solId, + polId, + totalMs, + rebuildMs, + ensureMs, + polPartialMs, + joHandledByMs, + postMs, + ) + } + } + private fun postWorkbenchPickSideEffects(savedStockOutLine: StockOutLine, deltaQty: BigDecimal, createLedger: Boolean = true) { if (deltaQty <= BigDecimal.ZERO) return val wall0 = System.nanoTime() var ledgerMs = 0L @@ -1971,7 +2057,9 @@ return MessageResponse( var poLinesFetchMs = 0L var poCompleteAndDoMs = 0L - ledgerMs = measureTimeMillis { createWorkbenchPickLedger(savedStockOutLine, deltaQty) } + if (createLedger) { + ledgerMs = measureTimeMillis { createWorkbenchPickLedger(savedStockOutLine, deltaQty) } + } try { val bagT0 = System.nanoTime() val solItem = savedStockOutLine.item @@ -2018,15 +2106,19 @@ return MessageResponse( polCompleteMs = measureTimeMillis { tryCompletePickOrderLineWorkbench(polId, solsForPol) } } polCheckMs = measureTimeMillis { checkWorkbenchPickOrderLineCompleted(polId, solsForPol) } - val pickOrder = pol.pickOrder - if (pickOrder != null && pickOrder.id != null) { - val poId = pickOrder.id!! + val poId = pol.pickOrder?.id ?: return + val freshPo = pickOrderRepository.findById(poId).orElse(null) ?: return + if (freshPo != null && freshPo.id != null) { + val poId = freshPo.id!! val t0 = System.nanoTime() - val allLines = pickOrderLineRepository.findAllByPickOrderIdAndDeletedFalse(poId) + //val allLines = pickOrderLineRepository.findAllByPickOrderIdAndDeletedFalse(poId) poLinesFetchMs = (System.nanoTime() - t0) / 1_000_000 // Split rows use pick_order_line.status = partially_completed until fully picked; do not complete header PO until every line is completed. - val allCompleted = allLines.all { it.status == PickOrderLineStatus.COMPLETED } - if (allCompleted && allLines.isNotEmpty()) { + // val allCompleted = allLines.all { it.status == PickOrderLineStatus.COMPLETED } + val total = freshPo.totalLines ?: 0 + val completed = freshPo.submittedLines ?: 0 + val allCompleted = total > 0 && completed >= total + if (allCompleted) { poCompleteAndDoMs = measureTimeMillis { // Use reload+retry to avoid optimistic lock when other flows update the same PO. completePickOrderWithRetry(poId) @@ -2100,13 +2192,18 @@ return MessageResponse( * Skips [checkAndCompletePickOrderByConsoCode] if POL was already completed (avoids full conso scan every pick). */ private fun tryCompletePickOrderLineWorkbench(pickOrderLineId: Long, sols: List) { - val polEntity = pickOrderLineRepository.findById(pickOrderLineId).orElse(null) ?: return - if (polEntity.status == PickOrderLineStatus.COMPLETED) return if (sols.isEmpty()) return val allEnded = sols.all { isWorkbenchSolEndStatus(it.status) } if (!allEnded) return - polEntity.status = PickOrderLineStatus.COMPLETED - pickOrderLineRepository.save(polEntity) + + val polEntity = pickOrderLineRepository.findById(pickOrderLineId).orElse(null) ?: return + val poId = polEntity.pickOrder?.id ?: return + + // Atomic gate: only one concurrent request can transition this POL to COMPLETED. + val changed = pickOrderLineRepository.markCompletedIfNotCompleted(pickOrderLineId) + if (changed > 0) { + pickOrderRepository.incrementSubmittedLines(poId) + } } /** diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrder.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrder.kt index ea3f0a9..9c140f5 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrder.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrder.kt @@ -66,4 +66,10 @@ open class PickOrder: BaseEntity() { @ManyToOne @JoinColumn(name = "assignTo", referencedColumnName = "id") open var assignTo: User? = null + + @Column(name = "totalLines") + open var totalLines: Int? = null + + @Column(name = "submittedLines") + open var submittedLines: Int? = null } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderLineRepository.kt index cf1df98..bdf8da2 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderLineRepository.kt @@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.pickOrder.entity import com.ffii.core.support.AbstractRepository import org.springframework.stereotype.Repository +import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param @Repository @@ -31,4 +32,18 @@ fun findByPickOrderId(pickOrderId: Long): List """ ) fun findAllByPickOrderIdInAndDeletedFalse(@Param("pickOrderIds") pickOrderIds: List): List + +@Modifying(clearAutomatically = true, flushAutomatically = true) +@Query( + value = """ + UPDATE fpsmsdb.pick_order_line + SET status = 'completed', + modified = CURRENT_TIMESTAMP + WHERE id = :pickOrderLineId + AND deleted = 0 + AND LOWER(COALESCE(status, '')) <> 'completed' + """, + nativeQuery = true, +) +fun markCompletedIfNotCompleted(@Param("pickOrderLineId") pickOrderLineId: Long): Int } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt index 4c8c012..b957faf 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt @@ -218,4 +218,17 @@ fun findAllReleasedJoWorkbenchPickOrders( @Param("status") status: PickOrderStatus, @Param("completedStatus") completedStatus: JobOrderStatus, ): List + +@Modifying(clearAutomatically = true, flushAutomatically = true) +@Query( + value = """ + UPDATE fpsmsdb.pick_order + SET submittedLines = COALESCE(submittedLines, 0) + 1, + modified = CURRENT_TIMESTAMP + WHERE id = :pickOrderId + AND deleted = 0 + """, + nativeQuery = true, +) +fun incrementSubmittedLines(@Param("pickOrderId") pickOrderId: Long): Int } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt index f00a937..4f0bee0 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt @@ -118,6 +118,8 @@ open class PickOrderService( this.targetDate = request.targetDate.atStartOfDay() this.type = request.type this.status = PickOrderStatus.PENDING + this.totalLines = request.pickOrderLine.size + this.submittedLines = 0 } val savedPickOrder = saveAndFlush(pickOrder) val polEntries = request.pickOrderLine.map { diff --git a/src/main/resources/db/changelog/changes/20260504_01_Enson/04_alter_stock_take.sql b/src/main/resources/db/changelog/changes/20260504_01_Enson/04_alter_stock_take.sql new file mode 100644 index 0000000..b7f4512 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260504_01_Enson/04_alter_stock_take.sql @@ -0,0 +1,8 @@ +--liquibase formatted sql + + + +--changeset Enson:20260507-01 +ALTER TABLE fpsmsdb.pick_order +ADD COLUMN `totalLines` INT(11) after `deliveryOrderPickOrderId`, +ADD COLUMN `submittedLines` INT(11) after `totalLines`;