diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt index e2ab2f4..c14a25f 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt @@ -28,6 +28,8 @@ import com.ffii.fpsms.modules.deliveryOrder.web.models.AssignByDoPickOrderIdRequ import com.ffii.fpsms.modules.user.entity.UserRepository import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderRecordRepository +import org.springframework.security.core.context.SecurityContext +import org.springframework.security.core.context.SecurityContextHolder import com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus import jakarta.persistence.OptimisticLockException import org.hibernate.StaleObjectStateException @@ -491,6 +493,7 @@ class DoReleaseCoordinatorService( return ids } } + /** FP-MTMS Version Checklist | Functions Ref. No. 77 | v1.0.0 | 2026-09-08 */ fun startBatchReleaseAsync(ids: List, userId: Long): MessageResponse { if (ids.isEmpty()) { return MessageResponse( @@ -502,10 +505,13 @@ class DoReleaseCoordinatorService( val jobId = UUID.randomUUID().toString() val status = BatchReleaseJobStatus(jobId = jobId, total = ids.size) jobs[jobId] = status + val workerSecurityContext = copySecurityContextForWorker() executor.submit { - batchReleaseConcurrencyGate.acquireUninterruptibly() try { + SecurityContextHolder.setContext(workerSecurityContext) + batchReleaseConcurrencyGate.acquireUninterruptibly() + try { try { println("Starting batch release for ${ids.size} orders (job $jobId)") val sortedIds = getOrderedDeliveryOrderIds(ids) @@ -643,6 +649,9 @@ class DoReleaseCoordinatorService( } finally { batchReleaseConcurrencyGate.release() } + } finally { + SecurityContextHolder.clearContext() + } } return MessageResponse( @@ -851,6 +860,7 @@ class DoReleaseCoordinatorService( ) } + /** FP-MTMS Version Checklist | Functions Ref. No. 77 | v1.0.0 | 2026-09-08 */ fun startBatchReleaseAsyncSingle(doId: Long, userId: Long): MessageResponse { val deliveryOrder = deliveryOrderRepository.findByIdAndDeletedIsFalse(doId) ?: return MessageResponse( @@ -858,8 +868,11 @@ class DoReleaseCoordinatorService( message = "Delivery Order not found", errorPosition = null, entity = null ) + val workerSecurityContext = copySecurityContextForWorker() + executor.submit { try { + SecurityContextHolder.setContext(workerSecurityContext) println("📦 Starting single release for DO $doId") // 调用 releaseDeliveryOrderWithoutTicket 创建 pick order @@ -958,6 +971,8 @@ class DoReleaseCoordinatorService( } catch (e: Exception) { println("❌ Single release exception: ${e.message}") e.printStackTrace() + } finally { + SecurityContextHolder.clearContext() } } @@ -968,6 +983,14 @@ class DoReleaseCoordinatorService( ) } + /** FP-MTMS Version Checklist | Functions Ref. No. 77 | v1.0.0 | 2026-09-08 */ + private fun copySecurityContextForWorker(): SecurityContext { + val parent = SecurityContextHolder.getContext() + val copy = SecurityContextHolder.createEmptyContext() + copy.authentication = parent.authentication + return copy + } + private fun updateSingleTicketNumbers() { try { // 1. 查找所有 TEMP- 开头的 single release type 订单 diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt index c16ee15..f44fe12 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt @@ -9,6 +9,9 @@ import com.ffii.fpsms.modules.deliveryOrder.web.models.WorkbenchMergeTicketCandi import com.ffii.fpsms.modules.master.web.models.MessageResponse import com.ffii.fpsms.modules.stock.service.StockOutLineWorkbenchService import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService +import com.ffii.fpsms.modules.user.entity.UserRepository +import org.springframework.security.core.context.SecurityContext +import org.springframework.security.core.context.SecurityContextHolder import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.Instant @@ -95,6 +98,7 @@ open class DoWorkbenchReleaseService( private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, private val stockOutLineWorkbenchService: StockOutLineWorkbenchService, private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, + private val userRepository: UserRepository, ) { private val poolSize = Runtime.getRuntime().availableProcessors() private val executor = Executors.newFixedThreadPool(kotlin.math.min(poolSize, 4)) @@ -142,6 +146,7 @@ open class DoWorkbenchReleaseService( open fun startBatchReleaseAsyncSingleV2(ids: List, userId: Long): MessageResponse = startBatchReleaseAsyncInternal(ids, userId, useV2 = true, dopReleaseType = "single") + /** FP-MTMS Version Checklist | Functions Ref. No. 78 | v1.0.0 | 2026-09-08 */ private fun startBatchReleaseAsyncInternal( ids: List, userId: Long, @@ -164,75 +169,83 @@ open class DoWorkbenchReleaseService( val jobId = UUID.randomUUID().toString() val status = WorkbenchBatchReleaseJobStatus(jobId = jobId, total = ids.size) jobs[jobId] = status + val actor = resolveReleaseActor(userId) + // Capture auth before worker thread: BaseEntity @PrePersist needs SecurityContext for pick_order / lines. + val workerSecurityContext = copySecurityContextForWorker() executor.submit { - batchReleaseConcurrencyGate.acquireUninterruptibly() try { - val orderedIds = getOrderedDeliveryOrderIds(ids) - val successResults = mutableListOf() + SecurityContextHolder.setContext(workerSecurityContext) + batchReleaseConcurrencyGate.acquireUninterruptibly() + try { + val orderedIds = getOrderedDeliveryOrderIds(ids) + val successResults = mutableListOf() - orderedIds.forEach { deliveryOrderId -> - try { - val statusRows = jdbcDao.queryForList( - """ - SELECT status - FROM fpsmsdb.delivery_order - WHERE id = :id AND deleted = 0 - """.trimIndent(), - mapOf("id" to deliveryOrderId) - ) - val currentStatus = statusRows.firstOrNull()?.get("status")?.toString()?.lowercase() - if (currentStatus == DeliveryOrderStatus.COMPLETED.value || currentStatus == DeliveryOrderStatus.RECEIVING.value) { - return@forEach - } + orderedIds.forEach { deliveryOrderId -> + try { + val statusRows = jdbcDao.queryForList( + """ + SELECT status + FROM fpsmsdb.delivery_order + WHERE id = :id AND deleted = 0 + """.trimIndent(), + mapOf("id" to deliveryOrderId) + ) + val currentStatus = statusRows.firstOrNull()?.get("status")?.toString()?.lowercase() + if (currentStatus == DeliveryOrderStatus.COMPLETED.value || currentStatus == DeliveryOrderStatus.RECEIVING.value) { + return@forEach + } - val released = releaseDeliveryOrderWorkbenchWithRetries(deliveryOrderId, userId, useV2) + val released = releaseDeliveryOrderWorkbenchWithRetries(deliveryOrderId, userId, useV2) - if (released != null) { - successResults += released - status.success.incrementAndGet() - } else { + if (released != null) { + successResults += released + status.success.incrementAndGet() + } else { + synchronized(status.failed) { + status.failed.add(deliveryOrderId to "Release returned null") + } + } + } catch (e: Exception) { synchronized(status.failed) { - status.failed.add(deliveryOrderId to "Release returned null") + status.failed.add(deliveryOrderId to (e.message ?: "Unknown error")) } } - } catch (e: Exception) { - synchronized(status.failed) { - status.failed.add(deliveryOrderId to (e.message ?: "Unknown error")) - } } - } - - try { - createAndLinkDeliveryOrderPickOrders(successResults, dopReleaseType, mergeExtraIntoLaneTicket) - } catch (e: Exception) { - // header-link failure shouldn't crash job; status.failed already includes per-DO failures - println("❌ workbench createAndLinkDeliveryOrderPickOrders failed: ${e.message}") - } - val replenishmentPickOrderIds = runWorkbenchReplenishmentRelease(successResults) + try { + createAndLinkDeliveryOrderPickOrders(successResults, dopReleaseType, mergeExtraIntoLaneTicket, actor) + } catch (e: Exception) { + // header-link failure shouldn't crash job; status.failed already includes per-DO failures + println("❌ workbench createAndLinkDeliveryOrderPickOrders failed: ${e.message}") + } - if (!useV2) { - val pickOrdersForDownstream = (successResults.map { it.pickOrderId } + replenishmentPickOrderIds).toSet() - pickOrdersForDownstream.forEach { pickOrderId -> - try { - suggestedPickLotWorkbenchService.rebuildNoHoldSuggestionsForPickOrder(pickOrderId) - stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderNoHold(pickOrderId, userId) - } catch (e: Exception) { - val deliveryOrderId = successResults.firstOrNull { it.pickOrderId == pickOrderId }?.deliveryOrderId - ?: 0L - synchronized(status.failed) { - status.failed.add( - deliveryOrderId to ("Downstream workbench step failed for pick order $pickOrderId: ${e.message}") - ) + val replenishmentPickOrderIds = runWorkbenchReplenishmentRelease(successResults) + + if (!useV2) { + val pickOrdersForDownstream = (successResults.map { it.pickOrderId } + replenishmentPickOrderIds).toSet() + pickOrdersForDownstream.forEach { pickOrderId -> + try { + suggestedPickLotWorkbenchService.rebuildNoHoldSuggestionsForPickOrder(pickOrderId) + stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderNoHold(pickOrderId, userId) + } catch (e: Exception) { + val deliveryOrderId = successResults.firstOrNull { it.pickOrderId == pickOrderId }?.deliveryOrderId + ?: 0L + synchronized(status.failed) { + status.failed.add( + deliveryOrderId to ("Downstream workbench step failed for pick order $pickOrderId: ${e.message}") + ) + } } } } + } finally { + status.running = false + status.finishedAt = Instant.now().toEpochMilli() + batchReleaseConcurrencyGate.release() } } finally { - status.running = false - status.finishedAt = Instant.now().toEpochMilli() - batchReleaseConcurrencyGate.release() + SecurityContextHolder.clearContext() } } @@ -318,6 +331,7 @@ open class DoWorkbenchReleaseService( ) } + val actor = resolveReleaseActor(userId) val orderedIds = getOrderedDeliveryOrderIds(ids) val successResults = mutableListOf() val failed = mutableListOf>() @@ -350,7 +364,7 @@ open class DoWorkbenchReleaseService( } } - val createdHeaders = createAndLinkDeliveryOrderPickOrders(successResults, "batch", mergeExtraIntoLaneTicket) + val createdHeaders = createAndLinkDeliveryOrderPickOrders(successResults, "batch", mergeExtraIntoLaneTicket, actor) val replenishmentPickOrderIds = runWorkbenchReplenishmentRelease(successResults) if (!useV2) { val pickOrdersForDownstream = (successResults.map { it.pickOrderId } + replenishmentPickOrderIds).toSet() @@ -504,6 +518,37 @@ open class DoWorkbenchReleaseService( } } + /** + * FP-MTMS Version Checklist | Functions Ref. No. 78 | v1.0.0 | 2026-09-08 + * Username for ticket createdBy/modifiedBy. Capture on the request thread, then copy onto the worker. + */ + private fun resolveReleaseActor(userId: Long): String { + val fromSecurity = SecurityContextHolder.getContext().authentication?.name + ?.trim() + ?.takeIf { it.isNotEmpty() && !it.equals("anonymousUser", ignoreCase = true) } + ?.take(30) + if (fromSecurity != null) return fromSecurity + if (userId > 0L) { + val fromUser = userRepository.findById(userId).orElse(null)?.username + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.take(30) + if (fromUser != null) return fromUser + } + return "system" + } + + /** + * FP-MTMS Version Checklist | Functions Ref. No. 78 | v1.0.0 | 2026-09-08 + * Copy auth for the worker thread so BaseEntity @PrePersist matches JO / consumable pick-order creation. + */ + private fun copySecurityContextForWorker(): SecurityContext { + val parent = SecurityContextHolder.getContext() + val copy = SecurityContextHolder.createEmptyContext() + copy.authentication = parent.authentication + return copy + } + private fun resolveTicketFloorSegment(storeId: String?, isDefaultTruckLane: Boolean): String = if (isDefaultTruckLane) { WORKBENCH_TICKET_FLOOR_SEGMENT_DEFAULT_TRUCK @@ -837,7 +882,7 @@ open class DoWorkbenchReleaseService( } } - private fun setRelationshipIdSelf(headerId: Long) { + private fun setRelationshipIdSelf(headerId: Long, actor: String) { jdbcDao.executeUpdate( """ UPDATE fpsmsdb.delivery_order_pick_order @@ -849,7 +894,7 @@ open class DoWorkbenchReleaseService( mapOf( "headerId" to headerId, "modified" to LocalDateTime.now(), - "modifiedBy" to "system", + "modifiedBy" to actor, ), ) } @@ -914,10 +959,11 @@ open class DoWorkbenchReleaseService( isSingleRelease: Boolean, requiredDate: LocalDate, ticketFloorSegment: String, + actor: String, ): Long? { val ticketNo = nextDeliveryOrderPickOrderMergeTicketNo(requiredDate, ticketFloorSegment) val releaseType = WorkbenchReleaseTypeSupport.mergeTicketReleaseType(isSingleRelease) - return insertNewDeliveryOrderPickOrderHeader(first, storeId, releaseType, ticketNo) + return insertNewDeliveryOrderPickOrderHeader(first, storeId, releaseType, ticketNo, actor) } private fun linkPickOrdersToHeader(headerId: Long, group: List) { @@ -941,6 +987,7 @@ open class DoWorkbenchReleaseService( storeId: String?, releaseTypeCol: String, ticketNo: String, + actor: String, ): Long? { val now = LocalDateTime.now() jdbcDao.executeUpdate( @@ -970,9 +1017,9 @@ open class DoWorkbenchReleaseService( "ticketNo" to ticketNo, "releaseType" to releaseTypeCol, "created" to now, - "createdBy" to "system", + "createdBy" to actor, "modified" to now, - "modifiedBy" to "system", + "modifiedBy" to actor, ) ) return jdbcDao.queryForList( @@ -984,7 +1031,7 @@ open class DoWorkbenchReleaseService( LIMIT 1 """.trimIndent(), mapOf("ticketNo" to ticketNo) - ).firstOrNull()?.get("id")?.let { (it as Number).toLong() }?.also { setRelationshipIdSelf(it) } + ).firstOrNull()?.get("id")?.let { (it as Number).toLong() }?.also { setRelationshipIdSelf(it, actor) } } /** @@ -997,6 +1044,7 @@ open class DoWorkbenchReleaseService( isSingleRelease: Boolean, requiredDate: LocalDate, ticketFloorSegment: String, + actor: String, ): Int { val activeMerge = findActiveMergeTicket(first, storeId, isSingleRelease) if (activeMerge != null) { @@ -1010,14 +1058,14 @@ open class DoWorkbenchReleaseService( val sourceToRetire = plainBatch ?: legacyUpgraded if (sourceToRetire != null) { - val mergeHeaderId = createNewMergeHeader(first, storeId, isSingleRelease, requiredDate, ticketFloorSegment) + val mergeHeaderId = createNewMergeHeader(first, storeId, isSingleRelease, requiredDate, ticketFloorSegment, actor) ?: return 0 retireSourceTicketIntoMergeHeader(sourceToRetire.id, mergeHeaderId) linkPickOrdersToHeader(mergeHeaderId, group) return 1 } - val mergeHeaderId = createNewMergeHeader(first, storeId, isSingleRelease, requiredDate, ticketFloorSegment) + val mergeHeaderId = createNewMergeHeader(first, storeId, isSingleRelease, requiredDate, ticketFloorSegment, actor) ?: return 0 linkPickOrdersToHeader(mergeHeaderId, group) return 1 @@ -1033,6 +1081,7 @@ open class DoWorkbenchReleaseService( results: List, dopReleaseType: String = "batch", mergeExtraIntoLaneTicket: Boolean = true, + actor: String, ): Int { if (results.isEmpty()) return 0 @@ -1064,6 +1113,7 @@ open class DoWorkbenchReleaseService( isSingleRelease, requiredDate, ticketFloorSegment, + actor, ) } @@ -1079,6 +1129,7 @@ open class DoWorkbenchReleaseService( storeId, WorkbenchReleaseTypeSupport.LEGACY_IS_EXTRA, ticketNo, + actor, ) ?: return 0 linkPickOrdersToHeader(headerId, group) return 1 @@ -1104,7 +1155,7 @@ open class DoWorkbenchReleaseService( } else { nextDeliveryOrderPickOrderBatchTicketNo(requiredDate, ticketFloorSegment) } - val headerId = insertNewDeliveryOrderPickOrderHeader(first, storeId, releaseTypeCol, ticketNo) + val headerId = insertNewDeliveryOrderPickOrderHeader(first, storeId, releaseTypeCol, ticketNo, actor) ?: return 0 linkPickOrdersToHeader(headerId, group) return 1 @@ -1206,6 +1257,7 @@ open class DoWorkbenchReleaseService( isSingleRelease = isSingleRelease, requiredDate = requiredDate, ticketFloorSegment = ticketFloorSegment, + actor = resolveReleaseActor(0L), ) ?: return mergeTicketsError("CREATE_FAILED", "Failed to create TI-M merge ticket") } @@ -1473,6 +1525,7 @@ open class DoWorkbenchReleaseService( isSingleRelease: Boolean, requiredDate: LocalDate, ticketFloorSegment: String, + actor: String, ): Long? { val ticketNo = nextDeliveryOrderPickOrderMergeTicketNo(requiredDate, ticketFloorSegment) val releaseType = WorkbenchReleaseTypeSupport.mergeTicketReleaseType(isSingleRelease) @@ -1504,9 +1557,9 @@ open class DoWorkbenchReleaseService( "ticketNo" to ticketNo, "releaseType" to releaseType, "created" to now, - "createdBy" to "system", + "createdBy" to actor, "modified" to now, - "modifiedBy" to "system", + "modifiedBy" to actor, ), ) return jdbcDao.queryForList( @@ -1515,6 +1568,6 @@ open class DoWorkbenchReleaseService( WHERE ticketNo = :ticketNo ORDER BY id DESC LIMIT 1 """.trimIndent(), mapOf("ticketNo" to ticketNo), - ).firstOrNull()?.get("id")?.let { (it as Number).toLong() }?.also { setRelationshipIdSelf(it) } + ).firstOrNull()?.get("id")?.let { (it as Number).toLong() }?.also { setRelationshipIdSelf(it, actor) } } }